1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-07-03 11:45:26 +02:00

Auth/PM-7324 - Registration with Email Verification - Registration Start Component Implementation (#9573)

* PM-7324 - Register new registration start comp at signup route on web

* PM-7324 - Add registerSendVerificationEmail logic in API service layer.

* PM-7324 - Update registration start comp to actually send information to API and trigger email.

* PM-7324 - progress on opt in for marketing emails redesign.

* PM-7324 - Add feature flag and feature flag guard to sign up route.

* PM-7324 - RegistrationEnvSelector - emit initial value

* PM-7324 - Registration Start comp - wire up setReceiveMarketingEmailsByRegion logic.

* PM-7324 - Registration start html - use proper link for email pref management.

* PM-7324 - Translate text

* PM-7324 - Design pass

* PM-7324 - design pass v2

* PM-7324 - Update Tailwind config to add availability of anon layout to desktop and browser extension

* PM-7324 - Desktop - AppRoutingModule - Add new signup route protected by the email verification feature flag.

* PM-7324 - BrowserExtension - AppRoutingModule - Add signup route protected by feature flag

* PM-7324 - Feature flag all register page navigations to redirect users to the new signup page.

* PM-7324 - Update AnonLayoutWrapperComponent constructor logic to avoid passing undefined values into I18nService.t method

* PM-7324 - Accept org invite web comp - adjust register url and qParams

* PM-7324 - Add AnonLayoutWrapperData to desktop & browser since we don't need titleId.

* PM-7324 - Revert anon layout wrapper comp changes as they were made separately and merged to main.

* PM-7234 - Fix registration start component so the login route works for the browser extension.

* PM-7324 - Registration start story now building again + fix storybook warning around BrowserAnimationsModule

* PM-7324 - Registration Start - add missing tw-text-main to fix dark mode rendering.

* PM-7324 - Update storybook docs

* PM-7324 - Get stub of registration finish component so that the verify email has something to land on.

* PM-7324 - Registration start - receive marketing materials should never be required.

* PM-7324 - Add finish signup route + required translations to desktop & browser.

* PM-7324 - AnonLayoutWrapperComponent - Resolve issues where navigating to a sibling anonymous route wouldn't update the AnonLayoutWrapperData.

* PM-7324 - Remove unnecessary array

* PM-7324  - Per PR feedback, improve setReceiveMarketingEmailsByRegion

* PM-7324 - Per PR feedback, inject login routes via route data

* PM-7324 - Document methods in account api service

* PM-7324 - PR feedback - jsdoc tweaks
This commit is contained in:
Jared Snider 2024-06-14 11:40:56 -04:00 committed by GitHub
parent eb96f7dbfb
commit 215bbc2f8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 584 additions and 88 deletions

View File

@ -16,6 +16,12 @@
"createAccount": {
"message": "Create account"
},
"setAStrongPassword": {
"message": "Set a strong password"
},
"finishCreatingYourAccountBySettingAPassword": {
"message": "Finish creating your account by setting a password"
},
"login": {
"message": "Log in"
},
@ -1780,6 +1786,21 @@
"masterPasswordPolicyRequirementsNotMet": {
"message": "Your new master password does not meet the policy requirements."
},
"receiveMarketingEmails": {
"message": "Get emails from Bitwarden for announcements, advice, and research opportunities."
},
"unsubscribe": {
"message": "Unsubscribe"
},
"atAnyTime": {
"message": "at any time."
},
"byContinuingYouAgreeToThe": {
"message": "By continuing, you agree to the"
},
"and": {
"message": "and"
},
"acceptPolicies": {
"message": "By checking this box you agree to the following:"
},

View File

@ -30,7 +30,9 @@
</form>
<p class="createAccountLink">
{{ "newAroundHere" | i18n }}
<a routerLink="/register" (click)="setLoginEmailValues()">{{ "createAccount" | i18n }}</a>
<a [routerLink]="registerRoute" (click)="setLoginEmailValues()">{{
"createAccount" | i18n
}}</a>
</p>
</div>
</div>

View File

@ -5,6 +5,8 @@ import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component";
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -26,6 +28,9 @@ export class HomeComponent implements OnInit, OnDestroy {
rememberEmail: [false],
});
// TODO: remove when email verification flag is removed
registerRoute = "/register";
constructor(
protected platformUtilsService: PlatformUtilsService,
private formBuilder: FormBuilder,
@ -34,9 +39,19 @@ export class HomeComponent implements OnInit, OnDestroy {
private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction,
private accountSwitcherService: AccountSwitcherService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
// TODO: remove when email verification flag is removed
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.registerRoute = "/signup";
}
const email = this.loginEmailService.getEmail();
const rememberEmail = this.loginEmailService.getRememberEmail();

View File

@ -13,6 +13,7 @@ import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstraction
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -51,6 +52,7 @@ export class LoginComponent extends BaseLoginComponent {
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
webAuthnLoginService: WebAuthnLoginServiceAbstraction,
configService: ConfigService,
) {
super(
devicesApiService,
@ -71,6 +73,7 @@ export class LoginComponent extends BaseLoginComponent {
loginEmailService,
ssoLoginService,
webAuthnLoginService,
configService,
);
super.onSuccessfulLogin = async () => {
await syncService.fullSync(true);

View File

@ -8,6 +8,16 @@ import {
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
RegistrationFinishComponent,
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard";
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
@ -343,6 +353,45 @@ const routes: Routes = [
canActivate: [AuthGuard],
data: { state: "update-temp-password" },
},
{
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: { pageTitle: "createAccount" } satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationStartComponent,
},
{
path: "",
component: RegistrationStartSecondaryComponent,
outlet: "secondary",
data: {
loginRoute: "/home",
} satisfies RegistrationStartSecondaryComponentData,
},
],
},
{
path: "finish-signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: {
pageTitle: "setAStrongPassword",
pageSubtitle: "finishCreatingYourAccountBySettingAPassword",
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationFinishComponent,
},
],
},
],
},
...extensionRefreshSwap(AboutPageComponent, AboutPageV2Component, {
path: "about",
canActivate: [AuthGuard],

View File

@ -4,6 +4,7 @@ const config = require("../../libs/components/tailwind.config.base");
config.content = [
"./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
];

View File

@ -6,7 +6,18 @@ import {
lockGuard,
redirectGuard,
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
RegistrationFinishComponent,
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
@ -82,6 +93,45 @@ const routes: Routes = [
canActivate: [AuthGuard],
data: { titleId: "removeMasterPassword" },
},
{
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: { pageTitle: "createAccount" } satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationStartComponent,
},
{
path: "",
component: RegistrationStartSecondaryComponent,
outlet: "secondary",
data: {
loginRoute: "/login",
} satisfies RegistrationStartSecondaryComponentData,
},
],
},
{
path: "finish-signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: {
pageTitle: "setAStrongPassword",
pageSubtitle: "finishCreatingYourAccountBySettingAPassword",
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationFinishComponent,
},
],
},
],
},
];
@NgModule({

View File

@ -48,7 +48,7 @@
</div>
<div class="sub-options">
<p class="no-margin">{{ "newAroundHere" | i18n }}</p>
<button type="button" class="text text-primary" routerLink="/register">
<button type="button" class="text text-primary" [routerLink]="registerRoute">
{{ "createAccount" | i18n }}
</button>
</div>

View File

@ -15,6 +15,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -71,6 +72,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
webAuthnLoginService: WebAuthnLoginServiceAbstraction,
configService: ConfigService,
) {
super(
devicesApiService,
@ -91,6 +93,7 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy {
loginEmailService,
ssoLoginService,
webAuthnLoginService,
configService,
);
super.onSuccessfulLogin = () => {
return syncService.fullSync(true);

View File

@ -499,6 +499,12 @@
"createAccount": {
"message": "Create account"
},
"setAStrongPassword": {
"message": "Set a strong password"
},
"finishCreatingYourAccountBySettingAPassword": {
"message": "Finish creating your account by setting a password"
},
"logIn": {
"message": "Log in"
},
@ -1659,6 +1665,21 @@
"masterPasswordPolicyRequirementsNotMet": {
"message": "Your new master password does not meet the policy requirements."
},
"receiveMarketingEmails": {
"message": "Get emails from Bitwarden for announcements, advice, and research opportunities."
},
"unsubscribe": {
"message": "Unsubscribe"
},
"atAnyTime": {
"message": "at any time."
},
"byContinuingYouAgreeToThe": {
"message": "By continuing, you agree to the"
},
"and": {
"message": "and"
},
"acceptPolicies": {
"message": "By checking this box you agree to the following:"
},

View File

@ -4,6 +4,7 @@ const config = require("../../libs/components/tailwind.config.base");
config.content = [
"./src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
"../../libs/angular/src/**/*.{html,ts}",
];

View File

@ -27,7 +27,7 @@ export class AcceptFamilySponsorshipComponent extends BaseAcceptComponent {
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/register"], { queryParams: { email: qParams.email } });
this.router.navigate([this.registerRoute], { queryParams: { email: qParams.email } });
}
}
}

View File

@ -29,7 +29,7 @@
<a
bitButton
buttonType="primary"
routerLink="/register"
[routerLink]="registerRoute"
[queryParams]="{ email: email }"
[block]="true"
>

View File

@ -2,6 +2,7 @@ import { Component } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -28,9 +29,10 @@ export class AcceptEmergencyComponent extends BaseAcceptComponent {
i18nService: I18nService,
route: ActivatedRoute,
authService: AuthService,
configService: ConfigService,
private emergencyAccessService: EmergencyAccessService,
) {
super(router, platformUtilsService, i18nService, route, authService);
super(router, platformUtilsService, i18nService, route, authService, configService);
}
async authedHandler(qParams: Params): Promise<void> {

View File

@ -56,7 +56,7 @@
<p class="tw-m-0 tw-text-sm">
{{ "newAroundHere" | i18n }}
<!--mousedown event is used over click because it prevents the validation from firing -->
<a routerLink="/register" (mousedown)="goToRegister()">{{ "createAccount" | i18n }}</a>
<a bitLink href="#" (mousedown)="goToRegister()">{{ "createAccount" | i18n }}</a>
</p>
</ng-container>

View File

@ -20,6 +20,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -67,6 +68,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
loginEmailService: LoginEmailServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
webAuthnLoginService: WebAuthnLoginServiceAbstraction,
configService: ConfigService,
) {
super(
devicesApiService,
@ -87,6 +89,7 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
loginEmailService,
ssoLoginService,
webAuthnLoginService,
configService,
);
this.onSuccessfulLoginNavigate = this.goAfterLogIn;
this.showPasswordless = flagEnabled("showPasswordless");
@ -165,11 +168,11 @@ export class LoginComponent extends BaseLoginComponent implements OnInit {
const email = this.formGroup.value.email;
if (email) {
await this.router.navigate(["/register"], { queryParams: { email: email } });
await this.router.navigate([this.registerRoute], { queryParams: { email: email } });
return;
}
await this.router.navigate(["/register"]);
await this.router.navigate([this.registerRoute]);
}
protected override async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {

View File

@ -32,7 +32,7 @@
{{ "logIn" | i18n }}
</a>
<a
routerLink="/register"
[routerLink]="registerRoute"
[queryParams]="{ email: email }"
class="btn btn-primary btn-block ml-2 mt-0"
>

View File

@ -2,6 +2,7 @@ import { Component } from "@angular/core";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -23,9 +24,10 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent {
i18nService: I18nService,
route: ActivatedRoute,
authService: AuthService,
configService: ConfigService,
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
) {
super(router, platformUtilsService, i18nService, route, authService);
super(router, platformUtilsService, i18nService, route, authService, configService);
}
async authedHandler(qParams: Params): Promise<void> {
@ -86,8 +88,26 @@ export class AcceptOrganizationComponent extends BaseAcceptComponent {
// if SSO is disabled OR if sso is enabled but the SSO login required policy is not enabled
// then send user to create account
await this.router.navigate(["/register"], {
queryParams: { email: invite.email, fromOrgInvite: true },
// TODO: update logic when email verification flag is removed
let queryParams: Params;
if (this.registerRoute === "/register") {
queryParams = {
fromOrgInvite: "true",
email: invite.email,
};
} else if (this.registerRoute === "/signup") {
// We have to override the base component route b/c it is correct for other components
// that extend the base accept comp. We don't need users to complete email verification
// if they are coming directly from an emailed org invite.
this.registerRoute = "/finish-signup";
queryParams = {
email: invite.email,
};
}
await this.router.navigate([this.registerRoute], {
queryParams: queryParams,
});
return;
}

View File

@ -5,6 +5,8 @@ import { first, switchMap, takeUntil } from "rxjs/operators";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@ -19,6 +21,9 @@ export abstract class BaseAcceptComponent implements OnInit {
protected failedShortMessage = "inviteAcceptFailedShort";
protected failedMessage = "inviteAcceptFailed";
// TODO: remove when email verification flag is removed
registerRoute = "/register";
private destroy$ = new Subject<void>();
constructor(
@ -27,12 +32,22 @@ export abstract class BaseAcceptComponent implements OnInit {
protected i18nService: I18nService,
protected route: ActivatedRoute,
protected authService: AuthService,
private configService: ConfigService,
) {}
abstract authedHandler(qParams: Params): Promise<void>;
abstract unauthedHandler(qParams: Params): Promise<void>;
ngOnInit() {
async ngOnInit() {
// TODO: remove when email verification flag is removed
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.registerRoute = "/signup";
}
this.route.queryParams
.pipe(
first(),

View File

@ -9,7 +9,16 @@ import {
UnauthGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/auth/angular";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
RegistrationFinishComponent,
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { flagEnabled, Flags } from "../utils/flags";
@ -175,6 +184,41 @@ const routes: Routes = [
path: "",
component: AnonLayoutWrapperComponent,
children: [
{
path: "signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: { pageTitle: "createAccount", titleId: "createAccount" } satisfies DataProperties &
AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationStartComponent,
},
{
path: "",
component: RegistrationStartSecondaryComponent,
outlet: "secondary",
data: {
loginRoute: "/login",
} satisfies RegistrationStartSecondaryComponentData,
},
],
},
{
path: "finish-signup",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
data: {
pageTitle: "setAStrongPassword",
pageSubtitle: "finishCreatingYourAccountBySettingAPassword",
titleId: "setAStrongPassword",
} satisfies DataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: RegistrationFinishComponent,
},
],
},
{
path: "sso",
canActivate: [unauthGuardFn()],

View File

@ -75,7 +75,7 @@
>Bitwarden Send</a
>
{{ "sendAccessTaglineOr" | i18n }}
<a bitLink routerLink="/register" target="_blank" rel="noreferrer">{{
<a bitLink [routerLink]="registerRoute" target="_blank" rel="noreferrer">{{
"sendAccessTaglineSignUp" | i18n
}}</a>
{{ "sendAccessTaglineTryToday" | i18n }}

View File

@ -2,7 +2,9 @@ import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -54,6 +56,9 @@ export class AccessComponent implements OnInit {
protected formGroup = this.formBuilder.group({});
// TODO: remove when email verification flag is removed
registerRoute = "/register";
private id: string;
private key: string;
@ -64,6 +69,7 @@ export class AccessComponent implements OnInit {
private sendApiService: SendApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private configService: ConfigService,
protected formBuilder: FormBuilder,
) {}
@ -81,7 +87,16 @@ export class AccessComponent implements OnInit {
return this.send.creatorIdentifier;
}
ngOnInit() {
async ngOnInit() {
// TODO: remove when email verification flag is removed
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.registerRoute = "/signup";
}
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.params.subscribe(async (params) => {
this.id = params.sendId;

View File

@ -713,6 +713,12 @@
"createAccount": {
"message": "Create account"
},
"setAStrongPassword": {
"message": "Set a strong password"
},
"finishCreatingYourAccountBySettingAPassword": {
"message": "Finish creating your account by setting a password"
},
"newAroundHere": {
"message": "New around here?"
},
@ -3724,6 +3730,21 @@
"nothingSelected": {
"message": "You have not selected anything."
},
"receiveMarketingEmails": {
"message": "Get emails from Bitwarden for announcements, advice, and research opportunities."
},
"unsubscribe": {
"message": "Unsubscribe"
},
"atAnyTime": {
"message": "at any time."
},
"byContinuingYouAgreeToThe": {
"message": "By continuing, you agree to the"
},
"and": {
"message": "and"
},
"acceptPolicies": {
"message": "By checking this box you agree to the following:"
},

View File

@ -31,7 +31,7 @@
<a
bitButton
buttonType="primary"
routerLink="/register"
[routerLink]="registerRoute"
[queryParams]="{ email: email }"
[block]="true"
>

View File

@ -4,6 +4,7 @@ import { ActivatedRoute, Params, Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderUserAcceptRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-accept.request";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component";
@ -26,8 +27,9 @@ export class AcceptProviderComponent extends BaseAcceptComponent {
authService: AuthService,
private apiService: ApiService,
platformUtilService: PlatformUtilsService,
configService: ConfigService,
) {
super(router, platformUtilService, i18nService, route, authService);
super(router, platformUtilService, i18nService, route, authService, configService);
}
async authedHandler(qParams: Params) {

View File

@ -14,7 +14,9 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -56,6 +58,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
// TODO: remove when email verification flag is removed
protected registerRoute = "/register";
protected forcePasswordResetRoute = "update-temp-password";
protected destroy$ = new Subject<void>();
@ -83,11 +87,21 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
protected loginEmailService: LoginEmailServiceAbstraction,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected webAuthnLoginService: WebAuthnLoginServiceAbstraction,
protected configService: ConfigService,
) {
super(environmentService, i18nService, platformUtilsService);
}
async ngOnInit() {
// TODO: remove when email verification flag is removed
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.registerRoute = "/signup";
}
this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
if (!params) {
return;

View File

@ -486,6 +486,7 @@ const safeProviders: SafeProvider[] = [
UserVerificationServiceAbstraction,
LogService,
InternalAccountService,
EnvironmentService,
],
}),
safeProvider({

View File

@ -1,5 +1,6 @@
import { Component } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
import { AnonLayoutComponent } from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -17,31 +18,64 @@ export interface AnonLayoutWrapperData {
templateUrl: "anon-layout-wrapper.component.html",
imports: [AnonLayoutComponent, RouterModule],
})
export class AnonLayoutWrapperComponent {
export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
protected showReadonlyHostname: boolean;
constructor(
private router: Router,
private route: ActivatedRoute,
private i18nService: I18nService,
) {
const routeData = this.route.snapshot.firstChild?.data;
) {}
if (!routeData) {
ngOnInit(): void {
// Set the initial page data on load
this.setAnonLayoutWrapperData(this.route.snapshot.firstChild?.data);
// Listen for page changes and update the page data appropriately
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
// reset page data on page changes
tap(() => this.resetPageData()),
switchMap(() => this.route.firstChild?.data || null),
takeUntil(this.destroy$),
)
.subscribe((firstChildRouteData: Data | null) => {
this.setAnonLayoutWrapperData(firstChildRouteData);
});
}
private setAnonLayoutWrapperData(firstChildRouteData: Data | null) {
if (!firstChildRouteData) {
return;
}
if (routeData["pageTitle"] !== undefined) {
this.pageTitle = this.i18nService.t(routeData["pageTitle"]);
if (firstChildRouteData["pageTitle"] !== undefined) {
this.pageTitle = this.i18nService.t(firstChildRouteData["pageTitle"]);
}
if (routeData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.i18nService.t(routeData["pageSubtitle"]);
if (firstChildRouteData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]);
}
this.pageIcon = routeData["pageIcon"];
this.showReadonlyHostname = routeData["showReadonlyHostname"];
this.pageIcon = firstChildRouteData["pageIcon"];
this.showReadonlyHostname = firstChildRouteData["showReadonlyHostname"];
}
private resetPageData() {
this.pageTitle = null;
this.pageSubtitle = null;
this.pageIcon = null;
this.showReadonlyHostname = null;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -17,5 +17,6 @@ export * from "./user-verification/user-verification-form-input.component";
// registration
export * from "./registration/registration-start/registration-start.component";
export * from "./registration/registration-finish/registration-finish.component";
export * from "./registration/registration-start/registration-start-secondary.component";
export * from "./registration/registration-env-selector/registration-env-selector.component";

View File

@ -93,6 +93,9 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
// Save this off so we can reset the value to the previously selected region
// if the self hosted settings are closed without saving.
this.selectedRegionFromEnv = selectedRegionFromEnv;
// Emit the initial value
this.selectedRegionChange.emit(selectedRegionFromEnv);
}),
takeUntil(this.destroy$),
)

View File

@ -0,0 +1 @@
<h3>This component will be built in the next phase of email verification work.</h3>

View File

@ -0,0 +1,15 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@Component({
standalone: true,
selector: "auth-registration-finish",
templateUrl: "./registration-finish.component.html",
imports: [CommonModule, JslibModule, RouterModule],
})
export class RegistrationFinishComponent {
constructor() {}
}

View File

@ -1,3 +1,3 @@
<span
>{{ "alreadyHaveAccount" | i18n }} <a routerLink="/login">{{ "logIn" | i18n }}</a></span
>{{ "alreadyHaveAccount" | i18n }} <a [routerLink]="loginRoute">{{ "logIn" | i18n }}</a></span
>

View File

@ -1,15 +1,32 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
/**
* RegistrationStartSecondaryComponentData
* @loginRoute: string - The client specific route to the login page - configured at the app-routing.module level.
*/
export interface RegistrationStartSecondaryComponentData {
loginRoute: string;
}
@Component({
standalone: true,
selector: "auth-registration-start-secondary",
templateUrl: "./registration-start-secondary.component.html",
imports: [CommonModule, JslibModule, RouterModule],
})
export class RegistrationStartSecondaryComponent {
constructor() {}
export class RegistrationStartSecondaryComponent implements OnInit {
loginRoute: string;
constructor(private activatedRoute: ActivatedRoute) {}
async ngOnInit() {
const routeData = await firstValueFrom(this.activatedRoute.data);
this.loginRoute = routeData["loginRoute"];
}
}

View File

@ -23,38 +23,60 @@
<bit-form-control *ngIf="!isSelfHost">
<input
id="register-start-form-input-accept-policies"
id="register-start-form-input-receive-marketing-emails"
type="checkbox"
bitCheckbox
formControlName="acceptPolicies"
formControlName="receiveMarketingEmails"
/>
<bit-label for="register-start-form-input-accept-policies">
{{ "acceptPolicies" | i18n }}
<bit-label for="register-start-form-input-receive-marketing-emails">
{{ "receiveMarketingEmails" | i18n }}
<a
bitLink
linkType="primary"
href="https://bitwarden.com/terms/"
href="https://bitwarden.com/email-preferences"
target="_blank"
rel="noreferrer"
>{{ "termsOfService" | i18n }}</a
>,
<a
bitLink
linkType="primary"
href="https://bitwarden.com/privacy/"
target="_blank"
rel="noreferrer"
>{{ "privacyPolicy" | i18n }}</a
>{{ "unsubscribe" | i18n }}</a
>
{{ "atAnyTime" | i18n }}
</bit-label>
</bit-form-control>
<button [block]="true" type="submit" buttonType="primary" bitButton bitFormButton>
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
bitFormButton
class="tw-mb-3"
>
{{ "continue" | i18n }}
</button>
<p bitTypography="helper" class="tw-text-main tw-text-xs tw-mb-0">
{{ "byContinuingYouAgreeToThe" | i18n }}
<a
bitLink
linkType="primary"
href="https://bitwarden.com/terms/"
target="_blank"
rel="noreferrer"
>{{ "termsOfService" | i18n }}</a
>
{{ "and" | i18n }}
<a
bitLink
linkType="primary"
href="https://bitwarden.com/privacy/"
target="_blank"
rel="noreferrer"
>{{ "privacyPolicy" | i18n }}</a
>
</p>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary></form
></ng-container>
<ng-container *ngIf="state === RegistrationStartState.CHECK_EMAIL">
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center">
<bit-icon [icon]="Icons.RegistrationCheckEmailIcon" class="tw-mb-6"></bit-icon>

View File

@ -1,17 +1,12 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
import {
AbstractControl,
FormBuilder,
FormControl,
ReactiveFormsModule,
ValidatorFn,
Validators,
} from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { RegisterSendVerificationEmailRequest } from "@bitwarden/common/auth/models/request/registration/register-send-verification-email.request";
import { RegionConfig, Region } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
@ -31,6 +26,12 @@ export enum RegistrationStartState {
CHECK_EMAIL = "CheckEmail",
}
const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record<Region, boolean> = {
[Region.US]: true,
[Region.EU]: false,
[Region.SelfHosted]: false,
};
@Component({
standalone: true,
selector: "auth-registration-start",
@ -60,20 +61,19 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
formGroup = this.formBuilder.group({
email: ["", [Validators.required, Validators.email]],
name: [""],
acceptPolicies: [false, [this.acceptPoliciesValidator()]],
selectedRegion: [null],
receiveMarketingEmails: [false],
});
get email(): FormControl {
return this.formGroup.get("email") as FormControl;
get email() {
return this.formGroup.controls.email;
}
get name(): FormControl {
return this.formGroup.get("name") as FormControl;
get name() {
return this.formGroup.controls.name;
}
get acceptPolicies(): FormControl {
return this.formGroup.get("acceptPolicies") as FormControl;
get receiveMarketingEmails() {
return this.formGroup.controls.receiveMarketingEmails;
}
emailReadonly: boolean = false;
@ -86,8 +86,9 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder,
private route: ActivatedRoute,
private platformUtilsService: PlatformUtilsService,
private accountApiService: AccountApiService,
private router: Router,
) {
// TODO: this needs to update if user selects self hosted
this.isSelfHost = platformUtilsService.isSelfHost();
}
@ -107,6 +108,18 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
});
}
setReceiveMarketingEmailsByRegion(region: RegionConfig | Region.SelfHosted) {
let defaultValue;
if (region === Region.SelfHosted) {
defaultValue = DEFAULT_MARKETING_EMAILS_PREF_BY_REGION[region];
} else {
const regionKey = (region as RegionConfig).key;
defaultValue = DEFAULT_MARKETING_EMAILS_PREF_BY_REGION[regionKey];
}
this.receiveMarketingEmails.setValue(defaultValue);
}
submit = async () => {
const valid = this.validateForm();
@ -114,14 +127,31 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
return;
}
// TODO: Implement registration logic
const request: RegisterSendVerificationEmailRequest = new RegisterSendVerificationEmailRequest(
this.email.value,
this.name.value,
this.receiveMarketingEmails.value,
);
const result = await this.accountApiService.registerSendVerificationEmail(request);
if (typeof result === "string") {
// we received a token, so the env doesn't support email verification
// send the user directly to the finish registration page with the token as a query param
await this.router.navigate(["/finish-signup"], { queryParams: { token: result } });
}
// Result is null, so email verification is required
this.state = RegistrationStartState.CHECK_EMAIL;
this.registrationStartStateChange.emit(this.state);
};
handleSelectedRegionChange(region: RegionConfig | Region.SelfHosted | null) {
this.isSelfHost = region === Region.SelfHosted;
if (region !== null) {
this.setReceiveMarketingEmailsByRegion(region);
}
}
private validateForm(): boolean {
@ -139,14 +169,6 @@ export class RegistrationStartComponent implements OnInit, OnDestroy {
this.registrationStartStateChange.emit(this.state);
}
private acceptPoliciesValidator(): ValidatorFn {
return (control: AbstractControl) => {
const ctrlValue = control.value;
return !ctrlValue && !this.isSelfHost ? { required: true } : null;
};
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();

View File

@ -8,8 +8,9 @@ import * as stories from "./registration-start.stories";
The Auth-owned RegistrationStartComponent is to be used for the first step in the new email
verification stage gated registration process. It collects the environment (required), the user's
email address (required) and optionally their name. On cloud environments, it requires acceptance of
the terms of service and the privacy policy; the checkbox is hidden on self hosted environments.
email address (required) and optionally their name. On cloud environments, it offers a checkbox for
the user to choose to receive marketing emails or not with the default value changing based on the
environment (e.g., true for US, false for EU).
## Web Examples
@ -36,8 +37,10 @@ field will be set to readonly. `emailReadonly` is primarily for the organization
Behavior to note:
- The self hosted option is present in the environment selector.
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
will disappear.
- If you go from non-self hosted to self hosted, the receive marketing emails checkbox will
disappear.
- If you change regions, the receive marketing emails checkbox default value will change based on
the region.
### US Region
@ -49,8 +52,8 @@ Behavior to note:
### Self Hosted
Note the fact that the terms of service and privacy policy checkbox is not present when the
environment is self hosted.
Note the fact that the receive marketing emails checkbox is not present when the environment is self
hosted.
<Story of={stories.DesktopSelfHostExample} />
@ -59,8 +62,10 @@ environment is self hosted.
Behavior to note:
- The self hosted option is present in the environment selector.
- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox
will disappear.
- If you go from non-self hosted to self hosted, the receive marketing emails checkbox will
disappear.
- If you change regions, the receive marketing emails checkbox default value will change based on
the region.
### US Region
@ -72,7 +77,7 @@ Behavior to note:
### Self Hosted
Note the fact that the terms of service and privacy policy checkbox is not present when the
environment is self hosted.
Note the fact that the receive marketing emails checkbox is not present when the environment is self
hosted.
<Story of={stories.BrowserExtensionSelfHostExample} />

View File

@ -6,6 +6,7 @@ import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { ClientType } from "@bitwarden/common/enums";
import {
Environment,
@ -53,7 +54,6 @@ const decorators = (options: {
LinkModule,
TypographyModule,
AsyncActionsModule,
BrowserAnimationsModule,
],
providers: [
{
@ -64,6 +64,7 @@ const decorators = (options: {
}),
applicationConfig({
providers: [
importProvidersFrom(BrowserAnimationsModule),
importProvidersFrom(PreloadedEnglishI18nModule),
{
provide: EnvironmentService,
@ -91,6 +92,12 @@ const decorators = (options: {
showToast: (options: ToastOptions) => {},
} as Partial<ToastService>,
},
{
provide: AccountApiService,
useValue: {
registerSendVerificationEmail: () => Promise.resolve(null),
} as Partial<AccountApiService>,
},
],
}),
];

View File

@ -1,5 +1,27 @@
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { Verification } from "../types/verification";
export abstract class AccountApiService {
/**
* Deletes an account that has confirmed the operation is authorized
*
* @param verification - authorizes the account deletion operation.
* @returns A promise that resolves when the account is
* successfully deleted.
*/
abstract deleteAccount(verification: Verification): Promise<void>;
/**
* Sends a verification email as part of the registration process.
*
* @param request - The request object containing
* information needed to send the verification email, such as the user's email address.
* @returns A promise that resolves to a string tokencontaining the user's encrypted
* information which must be submitted to complete registration or `null` if
* email verification is enabled (users must get the token by clicking a
* link in the email that will be sent to them).
*/
abstract registerSendVerificationEmail(
request: RegisterSendVerificationEmailRequest,
): Promise<null | string>;
}

View File

@ -0,0 +1,7 @@
export class RegisterSendVerificationEmailRequest {
constructor(
public email: string,
public name: string,
public receiveMarketingEmails: boolean,
) {}
}

View File

@ -1,8 +1,13 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { ErrorResponse } from "../../models/response/error.response";
import { EnvironmentService } from "../../platform/abstractions/environment.service";
import { LogService } from "../../platform/abstractions/log.service";
import { AccountApiService } from "../abstractions/account-api.service";
import { InternalAccountService } from "../abstractions/account.service";
import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction";
import { RegisterSendVerificationEmailRequest } from "../models/request/registration/register-send-verification-email.request";
import { Verification } from "../types/verification";
export class AccountApiServiceImplementation implements AccountApiService {
@ -11,6 +16,7 @@ export class AccountApiServiceImplementation implements AccountApiService {
private userVerificationService: UserVerificationService,
private logService: LogService,
private accountService: InternalAccountService,
private environmentService: EnvironmentService,
) {}
async deleteAccount(verification: Verification): Promise<void> {
@ -23,4 +29,33 @@ export class AccountApiServiceImplementation implements AccountApiService {
throw e;
}
}
async registerSendVerificationEmail(
request: RegisterSendVerificationEmailRequest,
): Promise<null | string> {
const env = await firstValueFrom(this.environmentService.environment$);
try {
const response = await this.apiService.send(
"POST",
"/accounts/register/send-verification-email",
request,
false,
true,
env.getIdentityUrl(),
);
return response;
} catch (e: unknown) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 204) {
// No content is a success response.
return null;
}
}
this.logService.error(e);
throw e;
}
}
}

View File

@ -17,6 +17,7 @@ export enum FeatureFlag {
RestrictProviderAccess = "restrict-provider-access",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
BulkDeviceApproval = "bulk-device-approval",
EmailVerification = "email-verification",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@ -44,6 +45,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.RestrictProviderAccess]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
[FeatureFlag.BulkDeviceApproval]: FALSE,
[FeatureFlag.EmailVerification]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;