From 8c79e4f525fe1619a0d8a56e4e36e17bc35fb106 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Wed, 17 Aug 2022 15:15:45 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9F=C2=A0=F0=9F=8E=89=20Enable=20OAuth?= =?UTF-8?q?=20login=20(#15414)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 <168664+edmundito@users.noreply.github.com> * 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 <168664+edmundito@users.noreply.github.com> --- .../Analytics/useAnalyticsService.tsx | 4 +- .../hooks/services/Experiment/experiments.ts | 4 + .../src/packages/cloud/cloudRoutes.tsx | 4 +- .../packages/cloud/lib/auth/AuthProviders.ts | 2 + .../cloud/lib/auth/GoogleAuthService.ts | 33 ++--- .../src/packages/cloud/locales/en.json | 7 +- .../cloud/services/auth/AuthService.tsx | 117 +++++++++++++---- .../packages/cloud/services/auth/reducer.ts | 6 +- .../cloud/views/AcceptEmailInvite.tsx | 10 +- .../cloud/views/auth/LoginPage/LoginPage.tsx | 4 + .../auth/OAuthLogin/OAuthLogin.module.scss | 79 ++++++++++++ .../views/auth/OAuthLogin/OAuthLogin.test.tsx | 87 +++++++++++++ .../views/auth/OAuthLogin/OAuthLogin.tsx | 120 ++++++++++++++++++ .../auth/OAuthLogin/assets/github-logo.svg | 23 ++++ .../auth/OAuthLogin/assets/google-logo.svg | 63 +++++++++ .../cloud/views/auth/OAuthLogin/index.ts | 1 + .../views/auth/SignupPage/SignupPage.tsx | 5 +- .../components/SignupForm.module.scss | 4 + .../auth/SignupPage/components/SignupForm.tsx | 57 +++------ .../AccountSettingsView.tsx | 2 +- airbyte-webapp/src/utils/testutils.tsx | 2 +- 21 files changed, 534 insertions(+), 100 deletions(-) create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.module.scss create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.test.tsx create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/github-logo.svg create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/google-logo.svg create mode 100644 airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/index.ts diff --git a/airbyte-webapp/src/hooks/services/Analytics/useAnalyticsService.tsx b/airbyte-webapp/src/hooks/services/Analytics/useAnalyticsService.tsx index 4268c96f439a9..a038a0072e0a5 100644 --- a/airbyte-webapp/src/hooks/services/Analytics/useAnalyticsService.tsx +++ b/airbyte-webapp/src/hooks/services/Analytics/useAnalyticsService.tsx @@ -66,12 +66,12 @@ export const useAnalytics = (): AnalyticsServiceProviderValue => { return analyticsContext; }; -export const useAnalyticsIdentifyUser = (userId?: string): void => { +export const useAnalyticsIdentifyUser = (userId?: string, traits?: Record): void => { const analyticsService = useAnalyticsService(); useEffect(() => { if (userId) { - analyticsService.identify(userId); + analyticsService.identify(userId, traits); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId]); diff --git a/airbyte-webapp/src/hooks/services/Experiment/experiments.ts b/airbyte-webapp/src/hooks/services/Experiment/experiments.ts index fbfa0fd0dbbf3..18fe539d11e35 100644 --- a/airbyte-webapp/src/hooks/services/Experiment/experiments.ts +++ b/airbyte-webapp/src/hooks/services/Experiment/experiments.ts @@ -9,4 +9,8 @@ export interface Experiments { "authPage.hideSelfHostedCTA": boolean; "authPage.signup.hideName": boolean; "authPage.signup.hideCompanyName": boolean; + "authPage.oauth.google": boolean; + "authPage.oauth.github": boolean; + "authPage.oauth.google.signUpPage": boolean; + "authPage.oauth.github.signUpPage": boolean; } diff --git a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx index a7d92c53da779..cb99aec9888fb 100644 --- a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx +++ b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx @@ -136,7 +136,7 @@ const MainViewRoutes = () => { }; export const Routing: React.FC = () => { - const { user, inited } = useAuthService(); + const { user, inited, providers } = useAuthService(); const config = useConfig(); useFullStory(config.fullstory, config.fullstory.enabled, user); @@ -156,7 +156,7 @@ export const Routing: React.FC = () => { [user] ); useAnalyticsRegisterValues(analyticsContext); - useAnalyticsIdentifyUser(user?.userId); + useAnalyticsIdentifyUser(user?.userId, { providers }); useTrackPageAnalytics(); if (!inited) { diff --git a/airbyte-webapp/src/packages/cloud/lib/auth/AuthProviders.ts b/airbyte-webapp/src/packages/cloud/lib/auth/AuthProviders.ts index 4d576b99359db..bb7aac024896b 100644 --- a/airbyte-webapp/src/packages/cloud/lib/auth/AuthProviders.ts +++ b/airbyte-webapp/src/packages/cloud/lib/auth/AuthProviders.ts @@ -1,3 +1,5 @@ export enum AuthProviders { GoogleIdentityPlatform = "google_identity_platform", } + +export type OAuthProviders = "github" | "google"; diff --git a/airbyte-webapp/src/packages/cloud/lib/auth/GoogleAuthService.ts b/airbyte-webapp/src/packages/cloud/lib/auth/GoogleAuthService.ts index 3b87a02a8658f..f39886b609099 100644 --- a/airbyte-webapp/src/packages/cloud/lib/auth/GoogleAuthService.ts +++ b/airbyte-webapp/src/packages/cloud/lib/auth/GoogleAuthService.ts @@ -1,3 +1,5 @@ +import type { OAuthProviders } from "./AuthProviders"; + import { Auth, User, @@ -15,35 +17,16 @@ import { updatePassword, updateEmail, AuthErrorCodes, + signInWithPopup, + GoogleAuthProvider, + GithubAuthProvider, } from "firebase/auth"; import { Provider } from "config"; import { FieldError } from "packages/cloud/lib/errors/FieldError"; import { EmailLinkErrorCodes, ErrorCodes } from "packages/cloud/services/auth/types"; -interface AuthService { - login(email: string, password: string): Promise; - - signOut(): Promise; - - signUp(email: string, password: string): Promise; - - reauthenticate(email: string, passwordPassword: string): Promise; - - updatePassword(newPassword: string): Promise; - - resetPassword(email: string): Promise; - - finishResetPassword(code: string, newPassword: string): Promise; - - sendEmailVerifiedLink(): Promise; - - updateEmail(email: string, password: string): Promise; - - signInWithEmailLink(email: string): Promise; -} - -export class GoogleAuthService implements AuthService { +export class GoogleAuthService { constructor(private firebaseAuthProvider: Provider) {} get auth(): Auth { @@ -54,6 +37,10 @@ export class GoogleAuthService implements AuthService { return this.auth.currentUser; } + async loginWithOAuth(provider: OAuthProviders) { + await signInWithPopup(this.auth, provider === "github" ? new GithubAuthProvider() : new GoogleAuthProvider()); + } + async login(email: string, password: string): Promise { return signInWithEmailAndPassword(this.auth, email, password).catch((err) => { switch (err.code) { diff --git a/airbyte-webapp/src/packages/cloud/locales/en.json b/airbyte-webapp/src/packages/cloud/locales/en.json index 1e4adea5f1266..83398b372238b 100644 --- a/airbyte-webapp/src/packages/cloud/locales/en.json +++ b/airbyte-webapp/src/packages/cloud/locales/en.json @@ -28,7 +28,7 @@ "login.companyName": "Company name*", "login.companyName.placeholder": "Acme Inc.", "login.subscribe": "Receive community and feature updates. You can unsubscribe any time. ", - "login.security": "By using the service, you agree to to our Terms of Service and Privacy\u00a0Policy.", + "login.disclaimer": "By signing up and continuing, you agree to our Terms of Service and Privacy Policy.", "login.inviteTitle": "Invite access", "login.inviteLinkExpired": "This invite link expired. A new invite link was sent to your email.", "login.inviteLinkInvalid": "This invite link is no longer valid.", @@ -36,6 +36,11 @@ "login.quoteText": "Airbyte has cut months of employee hours off 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.", "login.quoteAuthor": "Micah Mangione", "login.quoteAuthorJobTitle": "Director of Technology", + "login.oauth.or": "or", + "login.oauth.google": "Continue with Google", + "login.oauth.github": "Continue with GitHub", + "login.oauth.differentCredentialsError": "Use your email and password to sign in.", + "login.oauth.unknownError": "An unknown error happened during sign in: {error}", "confirmResetPassword.newPassword": "Enter a new password", "confirmResetPassword.success": "Your password has been reset. Please log in with the new password.", diff --git a/airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx b/airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx index 4991dfd4a0946..f4c41ed49698b 100644 --- a/airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx +++ b/airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx @@ -1,12 +1,14 @@ -import { User as FbUser } from "firebase/auth"; +import { User as FirebaseUser } from "firebase/auth"; import React, { useCallback, useContext, useMemo, useRef } from "react"; import { useQueryClient } from "react-query"; import { useEffectOnce } from "react-use"; +import { Observable, Subject } from "rxjs"; import { Action, Namespace } from "core/analytics"; +import { isCommonRequestError } from "core/request/CommonRequestError"; import { useAnalyticsService } from "hooks/services/Analytics"; import useTypesafeReducer from "hooks/useTypesafeReducer"; -import { AuthProviders } from "packages/cloud/lib/auth/AuthProviders"; +import { AuthProviders, OAuthProviders } from "packages/cloud/lib/auth/AuthProviders"; import { GoogleAuthService } from "packages/cloud/lib/auth/GoogleAuthService"; import { User } from "packages/cloud/lib/domain/users"; import { useGetUserService } from "packages/cloud/services/users/UserService"; @@ -39,13 +41,18 @@ export type AuthSendEmailVerification = () => Promise; export type AuthVerifyEmail = (code: string) => Promise; export type AuthLogout = () => Promise; +type OAuthLoginState = "waiting" | "loading" | "done"; + interface AuthContextApi { user: User | null; inited: boolean; emailVerified: boolean; isLoading: boolean; loggedOut: boolean; + providers: string[] | null; + hasPasswordLogin: () => boolean; login: AuthLogin; + loginWithOAuth: (provider: OAuthProviders) => Observable; signUpWithEmailLink: (form: { name: string; email: string; password: string; news: boolean }) => Promise; signUp: AuthSignUp; updatePassword: AuthUpdatePassword; @@ -70,10 +77,61 @@ export const AuthenticationProvider: React.FC = ({ children }) => { const analytics = useAnalyticsService(); const authService = useInitService(() => new GoogleAuthService(() => auth), [auth]); + /** + * Create a user object in the Airbyte database from an existing Firebase user. + * This will make sure that the user account is tracked in our database as well + * as create a workspace for that user. This method also takes care of sending + * the relevant user creation analytics events. + */ + const createAirbyteUser = async ( + firebaseUser: FirebaseUser, + userData: { name?: string; companyName?: string; news?: boolean } = {} + ): Promise => { + // Create the Airbyte user on our server + const user = await userService.create({ + authProvider: AuthProviders.GoogleIdentityPlatform, + authUserId: firebaseUser.uid, + email: firebaseUser.email ?? "", + name: userData.name ?? firebaseUser.displayName ?? "", + companyName: userData.companyName ?? "", + news: userData.news ?? false, + }); + + analytics.track(Namespace.USER, Action.CREATE, { + actionDescription: "New user registered", + user_id: firebaseUser.uid, + name: user.name, + email: user.email, + // Which login provider was used, e.g. "password", "google.com", "github.com" + provider: firebaseUser.providerData[0]?.providerId, + ...getUtmFromStorage(), + }); + + return user; + }; + const onAfterAuth = useCallback( - async (currentUser: FbUser, user?: User) => { - user ??= await userService.getByAuthId(currentUser.uid, AuthProviders.GoogleIdentityPlatform); - loggedIn({ user, emailVerified: currentUser.emailVerified }); + async (currentUser: FirebaseUser, user?: User) => { + try { + user ??= await userService.getByAuthId(currentUser.uid, AuthProviders.GoogleIdentityPlatform); + loggedIn({ + user, + emailVerified: currentUser.emailVerified, + providers: currentUser.providerData.map(({ providerId }) => providerId), + }); + } catch (e) { + if (isCommonRequestError(e) && e.status === 404) { + // If there is a firebase user but not database user we'll create a db user in this step + // and retry the onAfterAuth step. This will always happen when a user logins via OAuth + // the first time and we don't have a database user yet for them. In rare cases this can + // also happen for email/password users if they closed their browser or got some network + // errors in between creating the firebase user and the database user originally. + const user = await createAirbyteUser(currentUser); + await onAfterAuth(currentUser, user); + } else { + throw e; + } + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [userService] @@ -103,6 +161,10 @@ export const AuthenticationProvider: React.FC = ({ children }) => { isLoading: state.loading, emailVerified: state.emailVerified, loggedOut: state.loggedOut, + providers: state.providers, + hasPasswordLogin(): boolean { + return !!state.providers?.includes("password"); + }, async login(values: { email: string; password: string }): Promise { await authService.login(values.email, values.password); @@ -110,6 +172,26 @@ export const AuthenticationProvider: React.FC = ({ children }) => { await onAfterAuth(auth.currentUser); } }, + loginWithOAuth(provider): Observable { + const state = new Subject(); + try { + state.next("waiting"); + authService + .loginWithOAuth(provider) + .then(async () => { + state.next("loading"); + if (auth.currentUser) { + await onAfterAuth(auth.currentUser); + state.next("done"); + state.complete(); + } + }) + .catch((e) => state.error(e)); + } catch (e) { + state.error(e); + } + return state.asObservable(); + }, async logout(): Promise { await userService.revokeUserSession(); await authService.signOut(); @@ -148,7 +230,7 @@ export const AuthenticationProvider: React.FC = ({ children }) => { await authService.finishResetPassword(code, newPassword); }, async signUpWithEmailLink({ name, email, password, news }): Promise { - let firebaseUser: FbUser; + let firebaseUser: FirebaseUser; try { ({ user: firebaseUser } = await authService.signInWithEmailLink(email)); @@ -175,29 +257,14 @@ export const AuthenticationProvider: React.FC = ({ children }) => { news: boolean; }): Promise { // Create a user account in firebase - const { user: fbUser } = await authService.signUp(form.email, form.password); - - // Create the Airbyte user on our server - const user = await userService.create({ - authProvider: AuthProviders.GoogleIdentityPlatform, - authUserId: fbUser.uid, - email: form.email, - name: form.name, - companyName: form.companyName, - news: form.news, - }); + const { user: firebaseUser } = await authService.signUp(form.email, form.password); + + // Create a user in our database for that firebase user + await createAirbyteUser(firebaseUser, { name: form.name, companyName: form.companyName, news: form.news }); // Send verification mail via firebase await authService.sendEmailVerifiedLink(); - analytics.track(Namespace.USER, Action.CREATE, { - actionDescription: "New user registered", - user_id: fbUser.uid, - name: user.name, - email: user.email, - ...getUtmFromStorage(), - }); - if (auth.currentUser) { await onAfterAuth(auth.currentUser); } diff --git a/airbyte-webapp/src/packages/cloud/services/auth/reducer.ts b/airbyte-webapp/src/packages/cloud/services/auth/reducer.ts index 9297934c9a8d8..c4bb82557b6a5 100644 --- a/airbyte-webapp/src/packages/cloud/services/auth/reducer.ts +++ b/airbyte-webapp/src/packages/cloud/services/auth/reducer.ts @@ -4,7 +4,7 @@ import { User } from "packages/cloud/lib/domain/users"; export const actions = { authInited: createAction("AUTH_INITED")(), - loggedIn: createAction("LOGGED_IN")<{ user: User; emailVerified: boolean }>(), + loggedIn: createAction("LOGGED_IN")<{ user: User; emailVerified: boolean; providers: string[] }>(), emailVerified: createAction("EMAIL_VERIFIED")(), loggedOut: createAction("LOGGED_OUT")(), updateUserName: createAction("UPDATE_USER_NAME")<{ value: string }>(), @@ -18,6 +18,7 @@ export interface AuthServiceState { emailVerified: boolean; loading: boolean; loggedOut: boolean; + providers: string[] | null; } export const initialState: AuthServiceState = { @@ -26,6 +27,7 @@ export const initialState: AuthServiceState = { emailVerified: false, loading: false, loggedOut: false, + providers: null, }; export const authStateReducer = createReducer(initialState) @@ -40,6 +42,7 @@ export const authStateReducer = createReducer(initial ...state, currentUser: action.payload.user, emailVerified: action.payload.emailVerified, + providers: action.payload.providers, inited: true, loading: false, loggedOut: false, @@ -57,6 +60,7 @@ export const authStateReducer = createReducer(initial currentUser: null, emailVerified: false, loggedOut: true, + providers: null, }; }) .handleAction(actions.updateUserName, (state, action): AuthServiceState => { diff --git a/airbyte-webapp/src/packages/cloud/views/AcceptEmailInvite.tsx b/airbyte-webapp/src/packages/cloud/views/AcceptEmailInvite.tsx index 33bada75429dd..bcb481d22a0d1 100644 --- a/airbyte-webapp/src/packages/cloud/views/AcceptEmailInvite.tsx +++ b/airbyte-webapp/src/packages/cloud/views/AcceptEmailInvite.tsx @@ -10,11 +10,11 @@ import { EmailLinkErrorCodes } from "../services/auth/types"; import { FieldItem, Form } from "./auth/components/FormComponents"; import { FormTitle } from "./auth/components/FormTitle"; import { + Disclaimer, EmailField, NameField, NewsField, PasswordField, - SecurityField, SignupButton, SignupFormStatusMessage, } from "./auth/SignupPage/components/SignupForm"; @@ -23,7 +23,6 @@ const ValidationSchema = yup.object().shape({ name: yup.string().required("form.empty.error"), email: yup.string().email("form.email.error").required("form.empty.error"), password: yup.string().min(12, "signup.password.minLength").required("form.empty.error"), - security: yup.boolean().oneOf([true], "form.empty.error"), }); export const AcceptEmailInvite: React.FC = () => { @@ -37,7 +36,6 @@ export const AcceptEmailInvite: React.FC = () => { email: "", password: "", news: true, - security: false, }} validationSchema={ValidationSchema} onSubmit={async ({ name, email, password, news }, { setFieldError, setStatus }) => { @@ -58,7 +56,7 @@ export const AcceptEmailInvite: React.FC = () => { } }} > - {({ isSubmitting, status, values, isValid }) => ( + {({ isSubmitting, status, isValid }) => (
@@ -71,14 +69,14 @@ export const AcceptEmailInvite: React.FC = () => { - {status && {status}} + )} diff --git a/airbyte-webapp/src/packages/cloud/views/auth/LoginPage/LoginPage.tsx b/airbyte-webapp/src/packages/cloud/views/auth/LoginPage/LoginPage.tsx index 2e2368f4ea114..ca6275378ba2f 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/LoginPage/LoginPage.tsx +++ b/airbyte-webapp/src/packages/cloud/views/auth/LoginPage/LoginPage.tsx @@ -13,6 +13,8 @@ import { useAuthService } from "packages/cloud/services/auth/AuthService"; import { BottomBlock, FieldItem, Form } from "packages/cloud/views/auth/components/FormComponents"; import { FormTitle } from "packages/cloud/views/auth/components/FormTitle"; +import { OAuthLogin } from "../OAuthLogin"; +import { Disclaimer } from "../SignupPage/components/SignupForm"; import styles from "./LoginPage.module.scss"; const LoginPageValidationSchema = yup.object().shape({ @@ -104,6 +106,8 @@ const LoginPage: React.FC = () => { )} + + ); }; diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.module.scss b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.module.scss new file mode 100644 index 0000000000000..821b2357cfe40 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.module.scss @@ -0,0 +1,79 @@ +@use "../../../../../scss/variables" as vars; +@use "../../../../../scss/colors"; + +.separator { + display: flex; + margin: vars.$spacing-xl 0; + font-size: 16px; + text-align: center; + font-weight: bold; + color: colors.$grey-800; + text-transform: uppercase; + + &:before, + &:after { + content: ""; + flex: 1 1; + border-bottom: 1px solid colors.$grey-300; + margin: auto; + } + + &:before { + margin-right: vars.$spacing-md; + } + + &:after { + margin-left: vars.$spacing-md; + } +} + +.buttons { + display: grid; + grid-template-columns: 1fr; + gap: vars.$spacing-sm; +} + +.spinner { + text-align: center; +} + +.google, +.github { + justify-content: center; + display: flex; + align-items: center; + font-size: 16px; + font-weight: 500; + width: 100%; + margin: vars.$spacing-sm auto; + padding: vars.$spacing-md; + gap: vars.$spacing-md; + border-radius: vars.$border-radius-sm; + border: none; + transition: all vars.$transition; + cursor: pointer; + + &:hover, + &:focus { + box-shadow: 0 1px 3px rgba(53, 53, 66, 0.2), 0 1px 2px rgba(53, 53, 66, 0.12), 0 1px 1px rgba(53, 53, 66, 0.14); + } + + & > img { + height: 24px; + } +} + +.google { + background: colors.$white; + border: 1px solid colors.$grey-300; +} + +.github { + background: #333; + color: colors.$white; +} + +.error { + margin-top: vars.$spacing-lg; + color: colors.$red; +} diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.test.tsx b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.test.tsx new file mode 100644 index 0000000000000..798aded0d89b3 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.test.tsx @@ -0,0 +1,87 @@ +import { render } from "@testing-library/react"; +import userEvents from "@testing-library/user-event"; +import { EMPTY } from "rxjs"; + +import type { useExperiment } from "hooks/services/Experiment"; +import type { Experiments } from "hooks/services/Experiment/experiments"; +import { TestWrapper } from "utils/testutils"; + +const mockUseExperiment = jest.fn, Parameters>(); +jest.doMock("hooks/services/Experiment", () => ({ + useExperiment: mockUseExperiment, +})); + +const mockLoginWithOAuth = jest.fn(); +jest.doMock("packages/cloud/services/auth/AuthService", () => ({ + useAuthService: () => ({ + loginWithOAuth: mockLoginWithOAuth, + }), +})); + +// We need to use require here, so that this really happens after the doMock calls above +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { OAuthLogin } = require("./OAuthLogin"); + +const createUseExperimentMock = (options: { + google?: boolean; + github?: boolean; + googleSignUp?: boolean; + githubSignUp?: boolean; +}) => { + return (key: keyof Experiments) => { + switch (key) { + case "authPage.oauth.github": + return options.github ?? false; + case "authPage.oauth.google": + return options.google ?? false; + case "authPage.oauth.github.signUpPage": + return options.githubSignUp ?? true; + case "authPage.oauth.google.signUpPage": + return options.googleSignUp ?? true; + default: + throw new Error(`${key} is not mocked`); + } + }; +}; + +describe("OAuthLogin", () => { + beforeEach(() => { + mockUseExperiment.mockReset(); + mockUseExperiment.mockReturnValue(true); + mockLoginWithOAuth.mockReset(); + mockLoginWithOAuth.mockReturnValue(EMPTY); + }); + + it("should render all enabled logins", () => { + mockUseExperiment.mockImplementation(createUseExperimentMock({ google: true, github: true })); + const { getByTestId } = render(, { wrapper: TestWrapper }); + expect(getByTestId("googleOauthLogin")).toBeInTheDocument(); + expect(getByTestId("githubOauthLogin")).toBeInTheDocument(); + }); + + it("should not render buttons that are disabled", () => { + mockUseExperiment.mockImplementation(createUseExperimentMock({ google: false, github: true })); + const { getByTestId, queryByTestId } = render(, { wrapper: TestWrapper }); + expect(queryByTestId("googleOauthLogin")).not.toBeInTheDocument(); + expect(getByTestId("githubOauthLogin")).toBeInTheDocument(); + }); + + it("should not render disabled buttons for sign-up page", () => { + mockUseExperiment.mockImplementation(createUseExperimentMock({ google: true, github: true, googleSignUp: false })); + const { getByTestId, queryByTestId } = render(, { wrapper: TestWrapper }); + expect(queryByTestId("googleOauthLogin")).not.toBeInTheDocument(); + expect(getByTestId("githubOauthLogin")).toBeInTheDocument(); + }); + + it("should call auth service for Google", () => { + const { getByTestId } = render(, { wrapper: TestWrapper }); + userEvents.click(getByTestId("googleOauthLogin")); + expect(mockLoginWithOAuth).toHaveBeenCalledWith("google"); + }); + + it("should call auth service for GitHub", () => { + const { getByTestId } = render(, { wrapper: TestWrapper }); + userEvents.click(getByTestId("githubOauthLogin")); + expect(mockLoginWithOAuth).toHaveBeenCalledWith("github"); + }); +}); diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx new file mode 100644 index 0000000000000..74dfe99e4cb17 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/OAuthLogin.tsx @@ -0,0 +1,120 @@ +import { useRef, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useUnmount } from "react-use"; +import { Subscription } from "rxjs"; + +import { Spinner } from "components"; + +import { useExperiment } from "hooks/services/Experiment"; +import { OAuthProviders } from "packages/cloud/lib/auth/AuthProviders"; +import { useAuthService } from "packages/cloud/services/auth/AuthService"; + +import githubLogo from "./assets/github-logo.svg"; +import googleLogo from "./assets/google-logo.svg"; +import styles from "./OAuthLogin.module.scss"; + +const GitHubButton: React.FC<{ onClick: () => void }> = ({ onClick }) => { + return ( + + ); +}; + +const GoogleButton: React.FC<{ onClick: () => void }> = ({ onClick }) => { + return ( + + ); +}; + +interface OAuthLoginProps { + isSignUpPage?: boolean; +} + +export const OAuthLogin: React.FC = ({ isSignUpPage }) => { + const { formatMessage } = useIntl(); + const { loginWithOAuth } = useAuthService(); + const stateSubscription = useRef(); + const [errorCode, setErrorCode] = useState(); + const [isLoading, setLoading] = useState(false); + + const isGitHubLoginEnabled = useExperiment("authPage.oauth.github", false); + const isGoogleLoginEnabled = useExperiment("authPage.oauth.google", false); + const isGitHubEnabledOnSignUp = useExperiment("authPage.oauth.github.signUpPage", true); + const isGoogleEnabledOnSignUp = useExperiment("authPage.oauth.google.signUpPage", true); + + const showGoogleLogin = isGoogleLoginEnabled && (!isSignUpPage || isGoogleEnabledOnSignUp); + const showGitHubLogin = isGitHubLoginEnabled && (!isSignUpPage || isGitHubEnabledOnSignUp); + + const isAnyOauthEnabled = showGoogleLogin || showGitHubLogin; + + useUnmount(() => { + stateSubscription.current?.unsubscribe(); + }); + + if (!isAnyOauthEnabled) { + return null; + } + + const getErrorMessage = (error: string): string | undefined => { + switch (error) { + // The following error codes are not really errors, thus we'll ignore them. + case "auth/popup-closed-by-user": + case "auth/user-cancelled": + case "auth/cancelled-popup-request": + return undefined; + case "auth/account-exists-with-different-credential": + // Happens if a user requests and sets a password for an originally OAuth account. + // From them on they can't login via OAuth anymore unless it's Google OAuth. + return formatMessage({ id: "login.oauth.differentCredentialsError" }); + default: + return formatMessage({ id: "login.oauth.unknownError" }, { error }); + } + }; + + const login = (provider: OAuthProviders) => { + setErrorCode(undefined); + stateSubscription.current?.unsubscribe(); + stateSubscription.current = loginWithOAuth(provider).subscribe({ + next: (value) => { + if (value === "loading") { + setLoading(true); + } + if (value === "done") { + setLoading(false); + } + }, + error: (error) => { + if ("code" in error && typeof error.code === "string") { + setErrorCode(error.code); + } + }, + }); + }; + + const errorMessage = errorCode ? getErrorMessage(errorCode) : undefined; + + return ( +
+
+ +
+ {isLoading && ( +
+ +
+ )} + {!isLoading && ( +
+ {showGoogleLogin && login("google")} />} + {showGitHubLogin && login("github")} />} +
+ )} + {errorMessage &&
{errorMessage}
} +
+ ); +}; diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/github-logo.svg b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/github-logo.svg new file mode 100644 index 0000000000000..45affaad3a93b --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/github-logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/google-logo.svg b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/google-logo.svg new file mode 100644 index 0000000000000..bc558e76542ed --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/assets/google-logo.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/index.ts b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/index.ts new file mode 100644 index 0000000000000..b57b6ba4d5e39 --- /dev/null +++ b/airbyte-webapp/src/packages/cloud/views/auth/OAuthLogin/index.ts @@ -0,0 +1 @@ +export { OAuthLogin } from "./OAuthLogin"; diff --git a/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/SignupPage.tsx b/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/SignupPage.tsx index 2fc0966a6f948..c96e5ae1704bb 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/SignupPage.tsx +++ b/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/SignupPage.tsx @@ -3,7 +3,8 @@ import { FormattedMessage } from "react-intl"; import HeadTitle from "components/HeadTitle"; -import { SignupForm } from "./components/SignupForm"; +import { OAuthLogin } from "../OAuthLogin"; +import { Disclaimer, SignupForm } from "./components/SignupForm"; import SpecialBlock from "./components/SpecialBlock"; import styles from "./SignupPage.module.scss"; @@ -29,6 +30,8 @@ const SignupPage: React.FC = ({ highlightStyle }) => { + + ); }; diff --git a/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.module.scss b/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.module.scss index adfbf0f969598..a88c508c3586c 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.module.scss +++ b/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.module.scss @@ -13,3 +13,7 @@ margin-top: variables.$spacing-md; color: colors.$red; } + +.disclaimer { + margin-top: variables.$spacing-xl; +} diff --git a/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.tsx b/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.tsx index 087f6690b324c..81949dd34ebd0 100644 --- a/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.tsx +++ b/airbyte-webapp/src/packages/cloud/views/auth/SignupPage/components/SignupForm.tsx @@ -21,7 +21,6 @@ interface FormValues { email: string; password: string; news: boolean; - security: boolean; } const MarginBlock = styled.div` @@ -131,39 +130,26 @@ export const NewsField: React.FC = () => { ); }; -export const SecurityField: React.FC = () => { - const { formatMessage } = useIntl(); +export const Disclaimer: React.FC = () => { const config = useConfig(); - return ( - - {({ field, meta }: FieldProps) => ( - field.onChange(e)} - checked={!!field.value} - checkbox - label={ - ( - - {terms} - - ), - privacy: (privacy: React.ReactNode) => ( - - {privacy} - - ), - }} - /> - } - message={meta.touched && meta.error && formatMessage({ id: meta.error })} - /> - )} - +
+ ( + + {terms} + + ), + privacy: (privacy: React.ReactNode) => ( + + {privacy} + + ), + }} + /> +
); }; @@ -199,7 +185,6 @@ export const SignupForm: React.FC = () => { password: yup.string().min(12, "signup.password.minLength").required("form.empty.error"), name: yup.string(), companyName: yup.string(), - security: yup.boolean().oneOf([true], "form.empty.error"), }; if (showName) { shape.name = shape.name.required("form.empty.error"); @@ -218,7 +203,6 @@ export const SignupForm: React.FC = () => { email: "", password: "", news: true, - security: false, }} validationSchema={validationSchema} onSubmit={async (values, { setFieldError, setStatus }) => @@ -233,7 +217,7 @@ export const SignupForm: React.FC = () => { validateOnBlur validateOnChange > - {({ isValid, isSubmitting, values, status }) => ( + {({ isValid, isSubmitting, status }) => (
{(showName || showCompanyName) && ( @@ -250,10 +234,9 @@ export const SignupForm: React.FC = () => { - - + {status && {status}} diff --git a/airbyte-webapp/src/packages/cloud/views/users/AccountSettingsView/AccountSettingsView.tsx b/airbyte-webapp/src/packages/cloud/views/users/AccountSettingsView/AccountSettingsView.tsx index 730cbc85b3061..25ef797ee9c32 100644 --- a/airbyte-webapp/src/packages/cloud/views/users/AccountSettingsView/AccountSettingsView.tsx +++ b/airbyte-webapp/src/packages/cloud/views/users/AccountSettingsView/AccountSettingsView.tsx @@ -23,7 +23,7 @@ const AccountSettingsView: React.FC = () => { <> - + {authService.hasPasswordLogin() && } diff --git a/airbyte-webapp/src/utils/testutils.tsx b/airbyte-webapp/src/utils/testutils.tsx index ecbfc61f74977..56c5ffaa8208e 100644 --- a/airbyte-webapp/src/utils/testutils.tsx +++ b/airbyte-webapp/src/utils/testutils.tsx @@ -47,7 +47,7 @@ export async function render< } export const TestWrapper: React.FC = ({ children }) => ( - + null}> {children}