Skip to content

Commit 8c79e4f

Browse files
Tim Roesedmundito
andauthored
🪟 🎉 Enable OAuth login (#15414)
* Enable OAuth login * Style buttons * Make sure to hide error wrapper without error * Extract OAuthProviders type * Make google login button outline more visible * Add provider to segment identify call * Switch TOS checkbox by disclaimer * Address review feedback * Hide password change section for OAuth accounts * Update airbyte-webapp/src/packages/cloud/locales/en.json Co-authored-by: Edmundo Ruiz Ghanem <[email protected]> * Address review feedback * Add additional flags to disable on sign-up * Adding more tests * Review feedback * Fix broken linting Co-authored-by: Edmundo Ruiz Ghanem <[email protected]>
1 parent b811d8c commit 8c79e4f

File tree

21 files changed

+534
-100
lines changed

21 files changed

+534
-100
lines changed

airbyte-webapp/src/hooks/services/Analytics/useAnalyticsService.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ export const useAnalytics = (): AnalyticsServiceProviderValue => {
6666
return analyticsContext;
6767
};
6868

69-
export const useAnalyticsIdentifyUser = (userId?: string): void => {
69+
export const useAnalyticsIdentifyUser = (userId?: string, traits?: Record<string, unknown>): void => {
7070
const analyticsService = useAnalyticsService();
7171

7272
useEffect(() => {
7373
if (userId) {
74-
analyticsService.identify(userId);
74+
analyticsService.identify(userId, traits);
7575
}
7676
// eslint-disable-next-line react-hooks/exhaustive-deps
7777
}, [userId]);

airbyte-webapp/src/hooks/services/Experiment/experiments.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ export interface Experiments {
99
"authPage.hideSelfHostedCTA": boolean;
1010
"authPage.signup.hideName": boolean;
1111
"authPage.signup.hideCompanyName": boolean;
12+
"authPage.oauth.google": boolean;
13+
"authPage.oauth.github": boolean;
14+
"authPage.oauth.google.signUpPage": boolean;
15+
"authPage.oauth.github.signUpPage": boolean;
1216
}

airbyte-webapp/src/packages/cloud/cloudRoutes.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ const MainViewRoutes = () => {
136136
};
137137

138138
export const Routing: React.FC = () => {
139-
const { user, inited } = useAuthService();
139+
const { user, inited, providers } = useAuthService();
140140
const config = useConfig();
141141
useFullStory(config.fullstory, config.fullstory.enabled, user);
142142

@@ -156,7 +156,7 @@ export const Routing: React.FC = () => {
156156
[user]
157157
);
158158
useAnalyticsRegisterValues(analyticsContext);
159-
useAnalyticsIdentifyUser(user?.userId);
159+
useAnalyticsIdentifyUser(user?.userId, { providers });
160160
useTrackPageAnalytics();
161161

162162
if (!inited) {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export enum AuthProviders {
22
GoogleIdentityPlatform = "google_identity_platform",
33
}
4+
5+
export type OAuthProviders = "github" | "google";

airbyte-webapp/src/packages/cloud/lib/auth/GoogleAuthService.ts

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { OAuthProviders } from "./AuthProviders";
2+
13
import {
24
Auth,
35
User,
@@ -15,35 +17,16 @@ import {
1517
updatePassword,
1618
updateEmail,
1719
AuthErrorCodes,
20+
signInWithPopup,
21+
GoogleAuthProvider,
22+
GithubAuthProvider,
1823
} from "firebase/auth";
1924

2025
import { Provider } from "config";
2126
import { FieldError } from "packages/cloud/lib/errors/FieldError";
2227
import { EmailLinkErrorCodes, ErrorCodes } from "packages/cloud/services/auth/types";
2328

24-
interface AuthService {
25-
login(email: string, password: string): Promise<UserCredential>;
26-
27-
signOut(): Promise<void>;
28-
29-
signUp(email: string, password: string): Promise<UserCredential>;
30-
31-
reauthenticate(email: string, passwordPassword: string): Promise<UserCredential>;
32-
33-
updatePassword(newPassword: string): Promise<void>;
34-
35-
resetPassword(email: string): Promise<void>;
36-
37-
finishResetPassword(code: string, newPassword: string): Promise<void>;
38-
39-
sendEmailVerifiedLink(): Promise<void>;
40-
41-
updateEmail(email: string, password: string): Promise<void>;
42-
43-
signInWithEmailLink(email: string): Promise<UserCredential>;
44-
}
45-
46-
export class GoogleAuthService implements AuthService {
29+
export class GoogleAuthService {
4730
constructor(private firebaseAuthProvider: Provider<Auth>) {}
4831

4932
get auth(): Auth {
@@ -54,6 +37,10 @@ export class GoogleAuthService implements AuthService {
5437
return this.auth.currentUser;
5538
}
5639

40+
async loginWithOAuth(provider: OAuthProviders) {
41+
await signInWithPopup(this.auth, provider === "github" ? new GithubAuthProvider() : new GoogleAuthProvider());
42+
}
43+
5744
async login(email: string, password: string): Promise<UserCredential> {
5845
return signInWithEmailAndPassword(this.auth, email, password).catch((err) => {
5946
switch (err.code) {

airbyte-webapp/src/packages/cloud/locales/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,19 @@
2828
"login.companyName": "Company name*",
2929
"login.companyName.placeholder": "Acme Inc.",
3030
"login.subscribe": "Receive community and feature updates. You can unsubscribe any time. ",
31-
"login.security": "By using the service, you agree to to our <terms>Terms of Service</terms> and <privacy>Privacy\u00a0Policy</privacy>.",
31+
"login.disclaimer": "By signing up and continuing, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>.",
3232
"login.inviteTitle": "Invite access",
3333
"login.inviteLinkExpired": "This invite link expired. A new invite link was sent to your email.",
3434
"login.inviteLinkInvalid": "This invite link is no longer valid.",
3535
"login.rightSideFrameTitle": "More about Airbyte",
3636
"login.quoteText": "Airbyte has cut <b>months of employee hours off</b> of our ELT pipeline development and delivered usable data to us in hours instead of weeks. We are excited for the future of Airbyte, enthusiastic about their approach, and optimistic about our future together.",
3737
"login.quoteAuthor": "Micah Mangione",
3838
"login.quoteAuthorJobTitle": "Director of Technology",
39+
"login.oauth.or": "or",
40+
"login.oauth.google": "Continue with Google",
41+
"login.oauth.github": "Continue with GitHub",
42+
"login.oauth.differentCredentialsError": "Use your email and password to sign in.",
43+
"login.oauth.unknownError": "An unknown error happened during sign in: {error}",
3944

4045
"confirmResetPassword.newPassword": "Enter a new password",
4146
"confirmResetPassword.success": "Your password has been reset. Please log in with the new password.",

airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { User as FbUser } from "firebase/auth";
1+
import { User as FirebaseUser } from "firebase/auth";
22
import React, { useCallback, useContext, useMemo, useRef } from "react";
33
import { useQueryClient } from "react-query";
44
import { useEffectOnce } from "react-use";
5+
import { Observable, Subject } from "rxjs";
56

67
import { Action, Namespace } from "core/analytics";
8+
import { isCommonRequestError } from "core/request/CommonRequestError";
79
import { useAnalyticsService } from "hooks/services/Analytics";
810
import useTypesafeReducer from "hooks/useTypesafeReducer";
9-
import { AuthProviders } from "packages/cloud/lib/auth/AuthProviders";
11+
import { AuthProviders, OAuthProviders } from "packages/cloud/lib/auth/AuthProviders";
1012
import { GoogleAuthService } from "packages/cloud/lib/auth/GoogleAuthService";
1113
import { User } from "packages/cloud/lib/domain/users";
1214
import { useGetUserService } from "packages/cloud/services/users/UserService";
@@ -39,13 +41,18 @@ export type AuthSendEmailVerification = () => Promise<void>;
3941
export type AuthVerifyEmail = (code: string) => Promise<void>;
4042
export type AuthLogout = () => Promise<void>;
4143

44+
type OAuthLoginState = "waiting" | "loading" | "done";
45+
4246
interface AuthContextApi {
4347
user: User | null;
4448
inited: boolean;
4549
emailVerified: boolean;
4650
isLoading: boolean;
4751
loggedOut: boolean;
52+
providers: string[] | null;
53+
hasPasswordLogin: () => boolean;
4854
login: AuthLogin;
55+
loginWithOAuth: (provider: OAuthProviders) => Observable<OAuthLoginState>;
4956
signUpWithEmailLink: (form: { name: string; email: string; password: string; news: boolean }) => Promise<void>;
5057
signUp: AuthSignUp;
5158
updatePassword: AuthUpdatePassword;
@@ -70,10 +77,61 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
7077
const analytics = useAnalyticsService();
7178
const authService = useInitService(() => new GoogleAuthService(() => auth), [auth]);
7279

80+
/**
81+
* Create a user object in the Airbyte database from an existing Firebase user.
82+
* This will make sure that the user account is tracked in our database as well
83+
* as create a workspace for that user. This method also takes care of sending
84+
* the relevant user creation analytics events.
85+
*/
86+
const createAirbyteUser = async (
87+
firebaseUser: FirebaseUser,
88+
userData: { name?: string; companyName?: string; news?: boolean } = {}
89+
): Promise<User> => {
90+
// Create the Airbyte user on our server
91+
const user = await userService.create({
92+
authProvider: AuthProviders.GoogleIdentityPlatform,
93+
authUserId: firebaseUser.uid,
94+
email: firebaseUser.email ?? "",
95+
name: userData.name ?? firebaseUser.displayName ?? "",
96+
companyName: userData.companyName ?? "",
97+
news: userData.news ?? false,
98+
});
99+
100+
analytics.track(Namespace.USER, Action.CREATE, {
101+
actionDescription: "New user registered",
102+
user_id: firebaseUser.uid,
103+
name: user.name,
104+
email: user.email,
105+
// Which login provider was used, e.g. "password", "google.com", "github.com"
106+
provider: firebaseUser.providerData[0]?.providerId,
107+
...getUtmFromStorage(),
108+
});
109+
110+
return user;
111+
};
112+
73113
const onAfterAuth = useCallback(
74-
async (currentUser: FbUser, user?: User) => {
75-
user ??= await userService.getByAuthId(currentUser.uid, AuthProviders.GoogleIdentityPlatform);
76-
loggedIn({ user, emailVerified: currentUser.emailVerified });
114+
async (currentUser: FirebaseUser, user?: User) => {
115+
try {
116+
user ??= await userService.getByAuthId(currentUser.uid, AuthProviders.GoogleIdentityPlatform);
117+
loggedIn({
118+
user,
119+
emailVerified: currentUser.emailVerified,
120+
providers: currentUser.providerData.map(({ providerId }) => providerId),
121+
});
122+
} catch (e) {
123+
if (isCommonRequestError(e) && e.status === 404) {
124+
// If there is a firebase user but not database user we'll create a db user in this step
125+
// and retry the onAfterAuth step. This will always happen when a user logins via OAuth
126+
// the first time and we don't have a database user yet for them. In rare cases this can
127+
// also happen for email/password users if they closed their browser or got some network
128+
// errors in between creating the firebase user and the database user originally.
129+
const user = await createAirbyteUser(currentUser);
130+
await onAfterAuth(currentUser, user);
131+
} else {
132+
throw e;
133+
}
134+
}
77135
},
78136
// eslint-disable-next-line react-hooks/exhaustive-deps
79137
[userService]
@@ -103,13 +161,37 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
103161
isLoading: state.loading,
104162
emailVerified: state.emailVerified,
105163
loggedOut: state.loggedOut,
164+
providers: state.providers,
165+
hasPasswordLogin(): boolean {
166+
return !!state.providers?.includes("password");
167+
},
106168
async login(values: { email: string; password: string }): Promise<void> {
107169
await authService.login(values.email, values.password);
108170

109171
if (auth.currentUser) {
110172
await onAfterAuth(auth.currentUser);
111173
}
112174
},
175+
loginWithOAuth(provider): Observable<OAuthLoginState> {
176+
const state = new Subject<OAuthLoginState>();
177+
try {
178+
state.next("waiting");
179+
authService
180+
.loginWithOAuth(provider)
181+
.then(async () => {
182+
state.next("loading");
183+
if (auth.currentUser) {
184+
await onAfterAuth(auth.currentUser);
185+
state.next("done");
186+
state.complete();
187+
}
188+
})
189+
.catch((e) => state.error(e));
190+
} catch (e) {
191+
state.error(e);
192+
}
193+
return state.asObservable();
194+
},
113195
async logout(): Promise<void> {
114196
await userService.revokeUserSession();
115197
await authService.signOut();
@@ -148,7 +230,7 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
148230
await authService.finishResetPassword(code, newPassword);
149231
},
150232
async signUpWithEmailLink({ name, email, password, news }): Promise<void> {
151-
let firebaseUser: FbUser;
233+
let firebaseUser: FirebaseUser;
152234

153235
try {
154236
({ user: firebaseUser } = await authService.signInWithEmailLink(email));
@@ -175,29 +257,14 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
175257
news: boolean;
176258
}): Promise<void> {
177259
// Create a user account in firebase
178-
const { user: fbUser } = await authService.signUp(form.email, form.password);
179-
180-
// Create the Airbyte user on our server
181-
const user = await userService.create({
182-
authProvider: AuthProviders.GoogleIdentityPlatform,
183-
authUserId: fbUser.uid,
184-
email: form.email,
185-
name: form.name,
186-
companyName: form.companyName,
187-
news: form.news,
188-
});
260+
const { user: firebaseUser } = await authService.signUp(form.email, form.password);
261+
262+
// Create a user in our database for that firebase user
263+
await createAirbyteUser(firebaseUser, { name: form.name, companyName: form.companyName, news: form.news });
189264

190265
// Send verification mail via firebase
191266
await authService.sendEmailVerifiedLink();
192267

193-
analytics.track(Namespace.USER, Action.CREATE, {
194-
actionDescription: "New user registered",
195-
user_id: fbUser.uid,
196-
name: user.name,
197-
email: user.email,
198-
...getUtmFromStorage(),
199-
});
200-
201268
if (auth.currentUser) {
202269
await onAfterAuth(auth.currentUser);
203270
}

airbyte-webapp/src/packages/cloud/services/auth/reducer.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { User } from "packages/cloud/lib/domain/users";
44

55
export const actions = {
66
authInited: createAction("AUTH_INITED")<void>(),
7-
loggedIn: createAction("LOGGED_IN")<{ user: User; emailVerified: boolean }>(),
7+
loggedIn: createAction("LOGGED_IN")<{ user: User; emailVerified: boolean; providers: string[] }>(),
88
emailVerified: createAction("EMAIL_VERIFIED")<boolean>(),
99
loggedOut: createAction("LOGGED_OUT")<void>(),
1010
updateUserName: createAction("UPDATE_USER_NAME")<{ value: string }>(),
@@ -18,6 +18,7 @@ export interface AuthServiceState {
1818
emailVerified: boolean;
1919
loading: boolean;
2020
loggedOut: boolean;
21+
providers: string[] | null;
2122
}
2223

2324
export const initialState: AuthServiceState = {
@@ -26,6 +27,7 @@ export const initialState: AuthServiceState = {
2627
emailVerified: false,
2728
loading: false,
2829
loggedOut: false,
30+
providers: null,
2931
};
3032

3133
export const authStateReducer = createReducer<AuthServiceState, Actions>(initialState)
@@ -40,6 +42,7 @@ export const authStateReducer = createReducer<AuthServiceState, Actions>(initial
4042
...state,
4143
currentUser: action.payload.user,
4244
emailVerified: action.payload.emailVerified,
45+
providers: action.payload.providers,
4346
inited: true,
4447
loading: false,
4548
loggedOut: false,
@@ -57,6 +60,7 @@ export const authStateReducer = createReducer<AuthServiceState, Actions>(initial
5760
currentUser: null,
5861
emailVerified: false,
5962
loggedOut: true,
63+
providers: null,
6064
};
6165
})
6266
.handleAction(actions.updateUserName, (state, action): AuthServiceState => {

0 commit comments

Comments
 (0)