Skip to content

Commit

Permalink
🪟 Updated email invitation flow that enables invited users to set nam…
Browse files Browse the repository at this point in the history
…e and create password (#12788)

* First pass accepting email link invitation
* Update Auth service with signInWithEmailLink calls
* Add AcceptEmailInvite component
* Update FirebaseActionRoute to handle sign in mode
* Rename ResetPasswordAction to FirebseActionRoute

* Add create password setp to AcceptEmailInvite component

* Remove continueURL from invite fetch

* Update accept email invite for user to enter both email and password together

* Set name during email link signup

* Update AcceptEmailInvite to send name
* Add updateName to UserService
* Update AuthService to set name during sign up

* Remove steps from AcceptEmailInvite component
Remove setPassword from AuthService

* Add header and title to accept invite page

* Move invite error messages to en file

* For invite link pages, show login link instead of sign up

* Disable name update on sign in via email lnk

* Resend email invite when the invite link is expired

* Fix status message in accept email invite page

* Re-enable set user's name during sign up email invite

* Update signUpWithEmailLink so that sign up is successful even if we fail to update the user's name

* Update comments on GoogleAuthService signInWithEmailLink

* Add newsletter and accept terms checkboxes to accept email invite component
* Extract signup form from signup page
* Extract fields from signup form
* Update accept email invite component to use field components from signup form
* Ensure that sign up button is disable until form is valid and security checkbox is checked

* Make error status text color in accept email link red

* Update workspace check in DefaultView so that user lands in workspace selector when there are no workspaces

* Add coment around continueUrl param usage in UserService

* Remove usless default case in GoogleAuthService
  • Loading branch information
edmundito authored Jun 21, 2022
1 parent 32b5ed7 commit de0cf89
Show file tree
Hide file tree
Showing 13 changed files with 449 additions and 194 deletions.
24 changes: 23 additions & 1 deletion airbyte-webapp/src/packages/cloud/lib/auth/GoogleAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
UserCredential,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signInWithEmailLink,
sendPasswordResetEmail,
confirmPasswordReset,
updateProfile,
Expand All @@ -18,7 +19,7 @@ import {

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

interface AuthService {
login(email: string, password: string): Promise<UserCredential>;
Expand All @@ -38,6 +39,8 @@ interface AuthService {
sendEmailVerifiedLink(): Promise<void>;

updateEmail(email: string, password: string): Promise<void>;

signInWithEmailLink(email: string): Promise<UserCredential>;
}

export class GoogleAuthService implements AuthService {
Expand Down Expand Up @@ -153,6 +156,25 @@ export class GoogleAuthService implements AuthService {
return applyActionCode(this.auth, code);
}

async signInWithEmailLink(email: string): Promise<UserCredential> {
try {
return await signInWithEmailLink(this.auth, email);
} catch (e) {
switch (e?.code) {
case AuthErrorCodes.INVALID_EMAIL:
throw new FieldError("email", EmailLinkErrorCodes.EMAIL_MISMATCH);
case AuthErrorCodes.INVALID_OOB_CODE:
// The link was already used
throw new Error(EmailLinkErrorCodes.LINK_INVALID);
case AuthErrorCodes.EXPIRED_OOB_CODE:
// The link expired
throw new Error(EmailLinkErrorCodes.LINK_EXPIRED);
}

throw e;
}
}

signOut(): Promise<void> {
return this.auth.signOut();
}
Expand Down
17 changes: 15 additions & 2 deletions airbyte-webapp/src/packages/cloud/lib/domain/users/UserService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AirbyteRequestService } from "core/request/AirbyteRequestService";

import { User } from "./types";
import { User, UserUpdate } from "./types";

export class UserService extends AirbyteRequestService {
get url(): string {
Expand All @@ -20,6 +20,10 @@ export class UserService extends AirbyteRequestService {
});
}

public async update(params: UserUpdate): Promise<void> {
return this.fetch<void>(`${this.url}/update`, params);
}

public async changeEmail(email: string): Promise<void> {
return this.fetch<void>(`${this.url}/update`, {
email,
Expand All @@ -46,6 +50,14 @@ export class UserService extends AirbyteRequestService {
});
}

public async resendWithSignInLink({ email }: { email: string }): Promise<void> {
this.fetch(`v1/web_backend/cloud_workspaces/resend_with_signin_link`, {
email,
// `continueUrl` is rquired to have a valid URL, but it's currently not used by the Frontend.
continueUrl: window.location.href,
});
}

public async invite(
users: {
email: string;
Expand All @@ -54,9 +66,10 @@ export class UserService extends AirbyteRequestService {
): Promise<User[]> {
return Promise.all(
users.map(async (user) =>
this.fetch<User>(`v1/web_backend/cloud_workspaces/invite`, {
this.fetch<User>(`v1/web_backend/cloud_workspaces/invite_with_signin_link`, {
email: user.email,
workspaceId,
continueUrl: window.location.href,
})
)
);
Expand Down
14 changes: 13 additions & 1 deletion airbyte-webapp/src/packages/cloud/lib/domain/users/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
export type UserStatus = "invited" | "registered" | "disabled";

export interface User {
email: string;
name: string;
userId: string;
status?: "invited" | "registered" | "disabled";
status?: UserStatus;
intercomHash: string;
}

export interface UserUpdate {
userId: string;
authUserId: string;
name?: string;
defaultWorkspaceId?: string;
status?: UserStatus;
email?: string;
news?: boolean;
}
6 changes: 6 additions & 0 deletions airbyte-webapp/src/packages/cloud/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"login.loginTitle": "Sign in to Airbyte",
"login.resendEmail": "Didn’t receive the email? Send it again",
"login.yourEmail": "Your work email*",
"login.inviteEmail": "For security, re-enter your invite email*",
"login.yourEmail.placeholder": "work.email@example.com",
"login.yourEmail.notFound": "User not found",
"login.unknownError": "An unknown error has occurred",
Expand All @@ -15,6 +16,7 @@
"login.yourPassword.placeholder": "Your password",
"login.forgotPassword": "Forgot your password",
"login.backLogin": "Back to Log in",
"login.createPassword": "Create a password*",
"login.resetPassword": "Reset your password",
"login.resetPassword.emailSent": "A password reset email has been sent to you",
"login.activateAccess": "Activate your 14-day free trial",
Expand All @@ -25,6 +27,9 @@
"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>Terms of Service</terms> and <privacy>Privacy\u00a0Policy</privacy>.",
"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.",

"confirmResetPassword.newPassword": "Enter a new password",
"confirmResetPassword.success": "Your password has been reset. Please log in with the new password.",
Expand Down Expand Up @@ -115,6 +120,7 @@
"email.duplicate": "Email already exists",
"email.notfound": "Email not found",
"email.disabled": "Your account is disabled",
"email.inviteMismatch": "This email does not match the email address sent to this invite.",
"password.validation": "Your password is too weak",
"password.invalid": "Invalid password",

Expand Down
26 changes: 24 additions & 2 deletions airbyte-webapp/src/packages/cloud/services/auth/AuthService.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useInitService } from "services/useInitService";
import { getUtmFromStorage } from "utils/utmStorage";

import { actions, AuthServiceState, authStateReducer, initialState } from "./reducer";
import { EmailLinkErrorCodes } from "./types";

export type AuthUpdatePassword = (email: string, currentPassword: string, newPassword: string) => Promise<void>;

Expand Down Expand Up @@ -43,6 +44,7 @@ interface AuthContextApi {
isLoading: boolean;
loggedOut: boolean;
login: AuthLogin;
signUpWithEmailLink: (form: { name: string; email: string; password: string; news: boolean }) => Promise<void>;
signUp: AuthSignUp;
updatePassword: AuthUpdatePassword;
updateEmail: AuthChangeEmail;
Expand All @@ -66,8 +68,8 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
const authService = useInitService(() => new GoogleAuthService(() => auth), [auth]);

const onAfterAuth = useCallback(
async (currentUser: FbUser) => {
const user = await userService.getByAuthId(currentUser.uid, AuthProviders.GoogleIdentityPlatform);
async (currentUser: FbUser, user?: User) => {
user ??= await userService.getByAuthId(currentUser.uid, AuthProviders.GoogleIdentityPlatform);
loggedIn({ user, emailVerified: currentUser.emailVerified });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -133,6 +135,26 @@ export const AuthenticationProvider: React.FC = ({ children }) => {
async confirmPasswordReset(code: string, newPassword: string): Promise<void> {
await authService.finishResetPassword(code, newPassword);
},
async signUpWithEmailLink({ name, email, password, news }): Promise<void> {
let firebaseUser: FbUser;

try {
({ user: firebaseUser } = await authService.signInWithEmailLink(email));
await authService.updatePassword(password);
} catch (e) {
await authService.signOut();
if (e.message === EmailLinkErrorCodes.LINK_EXPIRED) {
await userService.resendWithSignInLink({ email });
}
throw e;
}

if (firebaseUser) {
const user = await userService.getByAuthId(firebaseUser.uid, AuthProviders.GoogleIdentityPlatform);
await userService.update({ userId: user.userId, authUserId: firebaseUser.uid, name, news });
await onAfterAuth(firebaseUser, { ...user, name });
}
},
async signUp(form: {
email: string;
password: string;
Expand Down
6 changes: 6 additions & 0 deletions airbyte-webapp/src/packages/cloud/services/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export enum ErrorCodes {
Invalid = "invalid",
Validation = "validation",
}

export const enum EmailLinkErrorCodes {
EMAIL_MISMATCH = "inviteMismatch",
LINK_EXPIRED = "inviteLinkExpired",
LINK_INVALID = "inviteLinkInvalid",
}
99 changes: 99 additions & 0 deletions airbyte-webapp/src/packages/cloud/views/AcceptEmailInvite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Formik } from "formik";
import { FormattedMessage, useIntl } from "react-intl";
import * as yup from "yup";

import { H1, LoadingButton } from "components";
import HeadTitle from "components/HeadTitle";

import { FieldError } from "../lib/errors/FieldError";
import { useAuthService } from "../services/auth/AuthService";
import { EmailLinkErrorCodes } from "../services/auth/types";
import { BottomBlock, BottomBlockStatusMessage, FieldItem, Form } from "./auth/components/FormComponents";
import {
EmailField,
NameField,
NewsField,
PasswordField,
SecurityField,
} from "./auth/SignupPage/components/SignupForm";

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 = () => {
const { formatMessage } = useIntl();
const authService = useAuthService();

const formElement = (
<Formik
initialValues={{
name: "",
email: "",
password: "",
news: true,
security: false,
}}
validationSchema={ValidationSchema}
onSubmit={async ({ name, email, password, news }, { setFieldError, setStatus }) => {
try {
await authService.signUpWithEmailLink({ name, email, password, news });
} catch (err) {
if (err instanceof FieldError) {
setFieldError(err.field, err.message);
} else {
setStatus(
formatMessage({
id: [EmailLinkErrorCodes.LINK_EXPIRED, EmailLinkErrorCodes.LINK_INVALID].includes(err.message)
? `login.${err.message}`
: "errorView.unknownError",
})
);
}
}
}}
>
{({ isSubmitting, status, values, isValid }) => (
<Form>
<FieldItem>
<NameField />
</FieldItem>
<FieldItem>
<EmailField label={<FormattedMessage id="login.inviteEmail" />} />
</FieldItem>
<FieldItem>
<PasswordField label={<FormattedMessage id="login.createPassword" />} />
</FieldItem>
<FieldItem>
<NewsField />
<SecurityField />
</FieldItem>
<BottomBlock>
<LoadingButton
type="submit"
isLoading={isSubmitting}
disabled={!isValid || !values.security}
data-testid="login.signup"
>
<FormattedMessage id="login.signup" />
</LoadingButton>
{status && <BottomBlockStatusMessage>{status}</BottomBlockStatusMessage>}
</BottomBlock>
</Form>
)}
</Formik>
);

return (
<>
<HeadTitle titles={[{ id: "login.inviteTitle" }]} />
<H1 bold>
<FormattedMessage id="login.inviteTitle" />
</H1>
{formElement}
</>
);
};
2 changes: 1 addition & 1 deletion airbyte-webapp/src/packages/cloud/views/DefaultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const DefaultView: React.FC = () => {
return (
<Navigate
to={
workspaces.length > 1
workspaces.length !== 1
? `/${CloudRoutes.SelectWorkspace}`
: `/${RoutePaths.Workspaces}/${workspaces[0].workspaceId}`
}
Expand Down
24 changes: 18 additions & 6 deletions airbyte-webapp/src/packages/cloud/views/FirebaseActionRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { useIntl } from "react-intl";
import { useNavigate } from "react-router-dom";
import { Navigate, useNavigate } from "react-router-dom";
import { useAsync } from "react-use";

import LoadingPage from "components/LoadingPage";
Expand All @@ -9,11 +9,14 @@ import { useNotificationService } from "hooks/services/Notification";
import useRouter from "hooks/useRouter";
import { useAuthService } from "packages/cloud/services/auth/AuthService";

import { CloudRoutes } from "../cloudRoutes";
import { AcceptEmailInvite } from "./AcceptEmailInvite";
import { ResetPasswordConfirmPage } from "./auth/ConfirmPasswordResetPage";

export enum FirebaseActionMode {
VERIFY_EMAIL = "verifyEmail",
RESET_PASSWORD = "resetPassword",
SIGN_IN = "signIn",
}

export const VerifyEmailAction: React.FC = () => {
Expand Down Expand Up @@ -43,11 +46,20 @@ export const VerifyEmailAction: React.FC = () => {
return query.mode === FirebaseActionMode.VERIFY_EMAIL ? <LoadingPage /> : null;
};

export const ResetPasswordAction: React.FC = () => {
const { query } = useRouter<{ mode: string }>();
export const FirebaseActionRoute: React.FC = () => {
const { query: { mode } = {} } = useRouter<{ mode: string }>();

if (query.mode === FirebaseActionMode.RESET_PASSWORD) {
return <ResetPasswordConfirmPage />;
switch (mode) {
case FirebaseActionMode.VERIFY_EMAIL:
return <VerifyEmailAction />;

case FirebaseActionMode.RESET_PASSWORD:
return <ResetPasswordConfirmPage />;

case FirebaseActionMode.SIGN_IN:
return <AcceptEmailInvite />;

default:
return <Navigate to={CloudRoutes.Login} replace />;
}
return <LoadingPage />;
};
Loading

0 comments on commit de0cf89

Please sign in to comment.