1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

Auth/PM-6198 - Registration with Email Verification - Call email clicked endpoint (#10139)

* PM-6198 - Majority of client work done; WIP on registration finish comp

* PM-6198 - Registration Finish - Add registerVerificationEmailClicked logic

* PM-6198 - RegistrationLinkExpired component; added translations on other clients just in case we use the component on other clients in the future.

* PM-6198 - Clean up comment
This commit is contained in:
Jared Snider 2024-07-18 17:37:22 -04:00 committed by GitHub
parent 158da35008
commit 56f5dba444
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 264 additions and 17 deletions

View File

@ -660,6 +660,21 @@
"loginExpired": { "loginExpired": {
"message": "Your login session has expired." "message": "Your login session has expired."
}, },
"logIn": {
"message": "Log in"
},
"restartRegistration": {
"message": "Restart registration"
},
"expiredLink": {
"message": "Expired link"
},
"pleaseRestartRegistrationOrTryLoggingIn": {
"message": "Please restart registration or try logging in."
},
"youMayAlreadyHaveAnAccount": {
"message": "You may already have an account"
},
"logOutConfirmation": { "logOutConfirmation": {
"message": "Are you sure you want to log out?" "message": "Are you sure you want to log out?"
}, },

View File

@ -792,6 +792,18 @@
"loginExpired": { "loginExpired": {
"message": "Your login session has expired." "message": "Your login session has expired."
}, },
"restartRegistration": {
"message": "Restart registration"
},
"expiredLink": {
"message": "Expired link"
},
"pleaseRestartRegistrationOrTryLoggingIn": {
"message": "Please restart registration or try logging in."
},
"youMayAlreadyHaveAnAccount": {
"message": "You may already have an account"
},
"logOutConfirmation": { "logOutConfirmation": {
"message": "Are you sure you want to log out?" "message": "Are you sure you want to log out?"
}, },

View File

@ -18,6 +18,7 @@ import {
RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData, RegistrationStartSecondaryComponentData,
LockIcon, LockIcon,
RegistrationLinkExpiredComponent,
} from "@bitwarden/auth/angular"; } from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@ -205,6 +206,22 @@ const routes: Routes = [
}, },
], ],
}, },
{
path: "signup-link-expired",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: {
pageTitle: "expiredLink",
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationLinkExpiredComponent,
data: {
loginRoute: "/login",
} satisfies RegistrationStartSecondaryComponentData,
},
],
},
{ {
path: "sso", path: "sso",
canActivate: [unauthGuardFn()], canActivate: [unauthGuardFn()],

View File

@ -627,6 +627,18 @@
"loginExpired": { "loginExpired": {
"message": "Your login session has expired." "message": "Your login session has expired."
}, },
"restartRegistration": {
"message": "Restart registration"
},
"expiredLink": {
"message": "Expired link"
},
"pleaseRestartRegistrationOrTryLoggingIn": {
"message": "Please restart registration or try logging in."
},
"youMayAlreadyHaveAnAccount": {
"message": "You may already have an account"
},
"logOutConfirmation": { "logOutConfirmation": {
"message": "Are you sure you want to log out?" "message": "Are you sure you want to log out?"
}, },

View File

@ -0,0 +1,20 @@
import { svgIcon } from "@bitwarden/components";
export const RegistrationExpiredLinkIcon = svgIcon`
<svg width="64" height="64" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8198_6255)">
<path
class="tw-fill-primary-600"
d="M2.70609 5.38455C3.71495 3.18341 5.95991 1.65214 8.56633 1.65214C12.1167 1.65214 14.9948 4.4927 14.9948 7.99672C14.9948 11.5007 12.1167 14.3413 8.56633 14.3413C5.90396 14.3413 3.61848 12.7438 2.64241 10.4652C2.53502 10.2144 2.24202 10.0971 1.98799 10.2031C1.73396 10.3091 1.61509 10.5983 1.72248 10.849C2.8493 13.4796 5.48864 15.327 8.56633 15.327C12.6683 15.327 15.9936 12.0451 15.9936 7.99672C15.9936 3.9483 12.6683 0.666412 8.56633 0.666412C5.41721 0.666412 2.72675 2.60033 1.64603 5.33002L0.858226 3.92917C0.734524 3.70921 0.455929 3.63117 0.235967 3.75487C0.0160044 3.87858 -0.0620299 4.15717 0.0616722 4.37713L1.35147 6.6706C1.47517 6.89056 1.75377 6.9686 1.97373 6.8449L4.2672 5.5551C4.48716 5.4314 4.5652 5.1528 4.44149 4.93284C4.31779 4.71288 4.0392 4.63484 3.81923 4.75854L2.70609 5.38455Z"
fill="#175DDC" />
<path
class="tw-fill-primary-600"
d="M10.7225 10.2926C10.6408 10.3474 10.5437 10.3767 10.4447 10.3767C10.318 10.3767 10.1962 10.3283 10.1041 10.2418L8.14837 8.41125C8.09985 8.36578 8.06123 8.31053 8.03449 8.24991C8.00775 8.18928 7.99389 8.12376 7.99389 8.05727V3.49271C7.99389 3.36314 8.04588 3.23896 8.13896 3.14704C8.23204 3.05512 8.3578 3.00378 8.48901 3.00378C8.62021 3.00378 8.74597 3.05561 8.83905 3.14704C8.93213 3.23847 8.98412 3.36314 8.98412 3.49271V7.84605L10.7853 9.53284C10.8571 9.60031 10.9071 9.68734 10.9284 9.78268C10.9497 9.87802 10.9413 9.97776 10.9047 10.0687C10.868 10.1596 10.8042 10.2379 10.7225 10.2926Z"
fill="#175DDC" />
</g>
<defs>
<clipPath id="clip0_8198_6255">
<rect width="15.9952" height="15.9952" fill="white" />
</clipPath>
</defs>
</svg>`;

View File

@ -20,6 +20,7 @@ export * from "./user-verification/user-verification-form-input.component";
// registration // registration
export * from "./registration/registration-start/registration-start.component"; export * from "./registration/registration-start/registration-start.component";
export * from "./registration/registration-finish/registration-finish.component"; export * from "./registration/registration-finish/registration-finish.component";
export * from "./registration/registration-link-expired/registration-link-expired.component";
export * from "./registration/registration-start/registration-start-secondary.component"; export * from "./registration/registration-start/registration-start-secondary.component";
export * from "./registration/registration-env-selector/registration-env-selector.component"; export * from "./registration/registration-env-selector/registration-env-selector.component";
export * from "./registration/registration-finish/registration-finish.service"; export * from "./registration/registration-finish/registration-finish.service";

View File

@ -1,10 +1,14 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router"; import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
import { Subject, takeUntil } from "rxjs"; import { Subject, from, switchMap, takeUntil, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { RegisterVerificationEmailClickedRequest } from "@bitwarden/common/auth/models/request/registration/register-verification-email-clicked.request";
import { HttpStatusCode } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
@ -41,33 +45,43 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
private i18nService: I18nService, private i18nService: I18nService,
private registrationFinishService: RegistrationFinishService, private registrationFinishService: RegistrationFinishService,
private validationService: ValidationService, private validationService: ValidationService,
private accountApiService: AccountApiService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
this.listenForQueryParamChanges(); this.listenForQueryParamChanges();
this.masterPasswordPolicyOptions = this.masterPasswordPolicyOptions =
await this.registrationFinishService.getMasterPasswordPolicyOptsFromOrgInvite(); await this.registrationFinishService.getMasterPasswordPolicyOptsFromOrgInvite();
this.loading = false;
} }
private listenForQueryParamChanges() { private listenForQueryParamChanges() {
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams: Params) => { this.activatedRoute.queryParams
if (qParams.email != null && qParams.email.indexOf("@") > -1) { .pipe(
this.email = qParams.email; tap((qParams: Params) => {
} if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
}
if (qParams.token != null) { if (qParams.token != null) {
this.emailVerificationToken = qParams.token; this.emailVerificationToken = qParams.token;
} }
}),
switchMap((qParams: Params) => {
if (
qParams.fromEmail &&
qParams.fromEmail === "true" &&
this.email &&
this.emailVerificationToken
) {
return from(
this.registerVerificationEmailClicked(this.email, this.emailVerificationToken),
);
}
}),
if (qParams.fromEmail && qParams.fromEmail === "true") { takeUntil(this.destroy$),
this.toastService.showToast({ )
title: null, .subscribe();
message: this.i18nService.t("emailVerifiedV2"),
variant: "success",
});
}
});
} }
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
@ -94,6 +108,48 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
await this.router.navigate(["/login"], { queryParams: { email: this.email } }); await this.router.navigate(["/login"], { queryParams: { email: this.email } });
} }
private async registerVerificationEmailClicked(email: string, emailVerificationToken: string) {
const request = new RegisterVerificationEmailClickedRequest(email, emailVerificationToken);
try {
const result = await this.accountApiService.registerVerificationEmailClicked(request);
if (result == null) {
this.toastService.showToast({
title: null,
message: this.i18nService.t("emailVerifiedV2"),
variant: "success",
});
this.loading = false;
}
} catch (e) {
await this.handleRegisterVerificationEmailClickedError(e);
this.loading = false;
}
}
private async handleRegisterVerificationEmailClickedError(e: unknown) {
if (e instanceof ErrorResponse) {
const errorResponse = e as ErrorResponse;
switch (errorResponse.statusCode) {
case HttpStatusCode.BadRequest: {
if (errorResponse.message.includes("Expired link")) {
await this.router.navigate(["/signup-link-expired"]);
} else {
this.validationService.showError(errorResponse);
}
break;
}
default:
this.validationService.showError(errorResponse);
break;
}
} else {
this.validationService.showError(e);
}
}
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();

View File

@ -0,0 +1,27 @@
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center">
<bit-icon [icon]="Icons.RegistrationExpiredLinkIcon" class="tw-mb-6"></bit-icon>
<p
bitTypography="body1"
class="tw-text-center tw-mb-3 tw-text-main"
id="restart_registration_body"
>
{{ "pleaseRestartRegistrationOrTryLoggingIn" | i18n }}<br />
{{ "youMayAlreadyHaveAnAccount" | i18n }}
</p>
<a
[block]="true"
type="button"
buttonType="primary"
bitButton
class="tw-mb-3"
routerLink="/signup"
>
{{ "restartRegistration" | i18n }}
</a>
<a [block]="true" type="button" buttonType="secondary" bitButton [routerLink]="loginRoute">
{{ "logIn" | i18n }}
</a>
</div>

View File

@ -0,0 +1,44 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Subject, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, IconModule } from "@bitwarden/components";
import { RegistrationExpiredLinkIcon } from "../../icons/registration-expired-link.icon";
/**
* RegistrationLinkExpiredComponentData
* @loginRoute: string - The client specific route to the login page - configured at the app-routing.module level.
*/
export interface RegistrationLinkExpiredComponentData {
loginRoute: string;
}
@Component({
standalone: true,
selector: "auth-registration-link-expired",
templateUrl: "./registration-link-expired.component.html",
imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule],
})
export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
loginRoute: string;
readonly Icons = { RegistrationExpiredLinkIcon };
constructor(private activatedRoute: ActivatedRoute) {}
async ngOnInit() {
const routeData = await firstValueFrom(this.activatedRoute.data);
this.loginRoute = routeData["loginRoute"];
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -1,5 +1,6 @@
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
import { Verification } from "../types/verification"; import { Verification } from "../types/verification";
export abstract class AccountApiService { export abstract class AccountApiService {
@ -26,6 +27,19 @@ export abstract class AccountApiService {
request: RegisterSendVerificationEmailRequest, request: RegisterSendVerificationEmailRequest,
): Promise<null | string>; ): Promise<null | string>;
/**
* Raises a server event to identify when users click the email verification link and land
* on the registration finish screen.
*
* @param request - The request object containing the email verification token and the
* user's email address (which is required to validate the token)
* @returns A promise that resolves when the event is logged on the server succcessfully or a bad
* request if the token is invalid for any reason.
*/
abstract registerVerificationEmailClicked(
request: RegisterVerificationEmailClickedRequest,
): Promise<void>;
/** /**
* Completes the registration process. * Completes the registration process.
* *

View File

@ -0,0 +1,6 @@
export class RegisterVerificationEmailClickedRequest {
constructor(
public email: string,
public emailVerificationToken: string,
) {}
}

View File

@ -9,6 +9,7 @@ import { InternalAccountService } from "../abstractions/account.service";
import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction";
import { RegisterFinishRequest } from "../models/request/registration/register-finish.request"; import { RegisterFinishRequest } from "../models/request/registration/register-finish.request";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request"; import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { RegisterVerificationEmailClickedRequest } from "../models/request/registration/register-verification-email-clicked.request";
import { Verification } from "../types/verification"; import { Verification } from "../types/verification";
export class AccountApiServiceImplementation implements AccountApiService { export class AccountApiServiceImplementation implements AccountApiService {
@ -60,6 +61,28 @@ export class AccountApiServiceImplementation implements AccountApiService {
} }
} }
async registerVerificationEmailClicked(
request: RegisterVerificationEmailClickedRequest,
): Promise<void> {
const env = await firstValueFrom(this.environmentService.environment$);
try {
const response = await this.apiService.send(
"POST",
"/accounts/register/verification-email-clicked",
request,
false,
false,
env.getIdentityUrl(),
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async registerFinish(request: RegisterFinishRequest): Promise<string> { async registerFinish(request: RegisterFinishRequest): Promise<string> {
const env = await firstValueFrom(this.environmentService.environment$); const env = await firstValueFrom(this.environmentService.environment$);