From 89d7e96b25594e51a7e8db6b227f06aeab79c543 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Fri, 24 May 2024 15:43:29 -0400 Subject: [PATCH] Auth/PM-5086 - Email Verification - Registration Start + Environment Selector components (#9342) * PM-5086 - WIP start on registration start component * PM-5086 - more WIP progress on registration start comp * PM-5086 - Setup secondary component * PM-5086 - (1) Validation working (2) States implemented (3) 2nd state for check email mostly completed except for correct icon * PM-5086 - Registration Start - check email state - update icon to be correct from figma. * PM-5086 - Refactor self hosted conditional + actually hide the checkbox if it is self hosted. * PM-5086 - WIP good progress on getting browser & desktop creating account on logic working. * PM-5086 - Accessibility pass + WIP on region selector * PM-5086 - Accessibility pass with Danielle * PM-5086 - Migrate env selector logic to own component. * PM-5086 - Update AnonLayoutWrapperComp import * PM-5086 - Remove unncessary focus. * PM-5086 - WIP first draft of registration env selector; name might change to differentiate it from existing env selector. * PM-5086 - Rename env selector to be more clear and use registration-env-selector instead. * PM-5086 - (1) Export registration env selector (2) Change comp name not just file name. * PM-5086 - Create new registration page stub * PM-5086 - Fix build issue where select module was missing from new registration env selector. * PM-5086 - Registration --> registration start. * PM-5086 - Add missing translation from registration-start-secondary-component to desktop & browser. * PM-5086 - Add missing translations * PM-5086 - Registration Env Selector - forms require form groups. duh. * PM-5086 - Registration Env Selector - working now. * PM-5086 - Registration Start desktop mostly working with env selector issues. * PM-5086 - Registration start - get self hosted env dialog to close on close click. Backdrop click doesn't work but escape does still. * PM-5086 - TODO: figure out if there is a better way to get the dialog to close. * PM-5086 - Registration start - get goBack working to properly re-show env selector * PM-5086 - Self Hosted Env Comp - re-emit current env on close so that select based env selectors can reset * PM-5086 - RegistrationEnvSelector - Refactor init logic to also listen for env updates so that the user's choices on the self hosted settings dialog get communicated to this comp * PM-5086 - Registration Start Desktop - Don't allow users to close dialog via escape as we need them to either close or save to get the env service to set the env correctly. * PM-5086 - Browser Registration Start Page stub * PM-5086 - Registration Start comp - storybook added * PM-5086 - Remove links to start-registration as we aren't ready to implement that yet. * PM-5086 - Revert environment comp changes. * PM-5086 - Delete registration start pages. * PM-5086 - Test removing PreloadedEnglishI18nModule to see if it fixes test failures * PM-5086 - Try to resolve issues w/ importing PreloadedEnglishI18nModule into RegistrationStartComponent storybook stories file. * PM-5086 - Allow translations to be imported for all libs. * PM-5086 - Remove comment from JSON * PM-5086 - TODO cleanup * PM-5086 - Per PR feedback, fix display issues by using correct classes. * PM-5086 - Fix SVG per PR feedback * PM-5086 - Remove unnecessary methods * PM-5086 - RegistrationEnvSelectorComponent - per PR feedback, properly type null in form group --- apps/browser/src/_locales/en/messages.json | 24 +++ apps/desktop/src/locales/en/messages.json | 24 +++ apps/web/src/locales/en/messages.json | 21 +++ .../icons/registration-check-email.icon.ts | 12 ++ libs/auth/src/angular/index.ts | 5 + .../registration-env-selector.component.html | 16 ++ .../registration-env-selector.component.ts | 101 ++++++++++++ ...egistration-start-secondary.component.html | 3 + .../registration-start-secondary.component.ts | 15 ++ .../registration-start.component.html | 83 ++++++++++ .../registration-start.component.ts | 146 ++++++++++++++++++ .../registration-start/registration-start.mdx | 28 ++++ .../registration-start.stories.ts | 74 +++++++++ libs/shared/tsconfig.libs.json | 1 + 14 files changed, 553 insertions(+) create mode 100644 libs/auth/src/angular/icons/registration-check-email.icon.ts create mode 100644 libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html create mode 100644 libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts create mode 100644 libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.html create mode 100644 libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts create mode 100644 libs/auth/src/angular/registration/registration-start/registration-start.component.html create mode 100644 libs/auth/src/angular/registration/registration-start/registration-start.component.ts create mode 100644 libs/auth/src/angular/registration/registration-start/registration-start.mdx create mode 100644 libs/auth/src/angular/registration/registration-start/registration-start.stories.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2c6741a967..bb672ea61d 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1606,6 +1606,9 @@ "restoredItem": { "message": "Item restored" }, + "alreadyHaveAccount": { + "message": "Already have an account?" + }, "vaultTimeoutLogOutConfirmation": { "message": "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?" }, @@ -2541,6 +2544,27 @@ "ssoIdentifierRequired": { "message": "Organization SSO identifier is required." }, + "creatingAccountOn": { + "message": "Creating account on" + }, + "checkYourEmail": { + "message": "Check your email" + }, + "followTheLinkInTheEmailSentTo": { + "message": "Follow the link in the email sent to" + }, + "andContinueCreatingYourAccount": { + "message": "and continue creating your account." + }, + "noEmail": { + "message": "No email?" + }, + "goBack": { + "message": "Go back" + }, + "toEditYourEmailAddress": { + "message": "to edit your email address." + }, "eu": { "message": "EU", "description": "European Union" diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 017b9d10c4..dde1763378 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2050,6 +2050,9 @@ "switchAccount": { "message": "Switch account" }, + "alreadyHaveAccount": { + "message": "Already have an account?" + }, "options": { "message": "Options" }, @@ -2390,6 +2393,27 @@ "logInRequested": { "message": "Log in requested" }, + "creatingAccountOn": { + "message": "Creating account on" + }, + "checkYourEmail": { + "message": "Check your email" + }, + "followTheLinkInTheEmailSentTo": { + "message": "Follow the link in the email sent to" + }, + "andContinueCreatingYourAccount": { + "message": "and continue creating your account." + }, + "noEmail": { + "message": "No email?" + }, + "goBack": { + "message": "Go back" + }, + "toEditYourEmailAddress": { + "message": "to edit your email address." + }, "exposedMasterPassword": { "message": "Exposed Master Password" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c4dbb33ac4..d4d3fc6d81 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3244,6 +3244,27 @@ "device": { "message": "Device" }, + "creatingAccountOn": { + "message": "Creating account on" + }, + "checkYourEmail": { + "message": "Check your email" + }, + "followTheLinkInTheEmailSentTo": { + "message": "Follow the link in the email sent to" + }, + "andContinueCreatingYourAccount": { + "message": "and continue creating your account." + }, + "noEmail": { + "message": "No email?" + }, + "goBack": { + "message": "Go back" + }, + "toEditYourEmailAddress": { + "message": "to edit your email address." + }, "view": { "message": "View" }, diff --git a/libs/auth/src/angular/icons/registration-check-email.icon.ts b/libs/auth/src/angular/icons/registration-check-email.icon.ts new file mode 100644 index 0000000000..1d173ff585 --- /dev/null +++ b/libs/auth/src/angular/icons/registration-check-email.icon.ts @@ -0,0 +1,12 @@ +import { svgIcon } from "@bitwarden/components"; + +export const RegistrationCheckEmailIcon = svgIcon` + + + + + + + + +`; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 474ef17d93..eb8fd0416a 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -14,3 +14,8 @@ export * from "./password-callout/password-callout.component"; export * from "./user-verification/user-verification-dialog.component"; export * from "./user-verification/user-verification-dialog.types"; export * from "./user-verification/user-verification-form-input.component"; + +// registration +export * from "./registration/registration-start/registration-start.component"; +export * from "./registration/registration-start/registration-start-secondary.component"; +export * from "./registration/registration-env-selector/registration-env-selector.component"; diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html new file mode 100644 index 0000000000..b4dad835ee --- /dev/null +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html @@ -0,0 +1,16 @@ +
+ + {{ "creatingAccountOn" | i18n }} + + + + + +
diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts new file mode 100644 index 0000000000..f01873dd3e --- /dev/null +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts @@ -0,0 +1,101 @@ +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; +import { EMPTY, Subject, from, map, of, switchMap, takeUntil, tap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + Environment, + EnvironmentService, + Region, + RegionConfig, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { FormFieldModule, SelectModule } from "@bitwarden/components"; + +@Component({ + standalone: true, + selector: "auth-registration-env-selector", + templateUrl: "registration-env-selector.component.html", + imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule], +}) +export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { + @Output() onOpenSelfHostedSettings = new EventEmitter(); + + ServerEnvironmentType = Region; + + formGroup = this.formBuilder.group({ + selectedRegion: [null as RegionConfig | Region.SelfHosted | null, Validators.required], + }); + + get selectedRegion(): FormControl { + return this.formGroup.get("selectedRegion") as FormControl; + } + + availableRegionConfigs: RegionConfig[] = this.environmentService.availableRegions(); + + private destroy$ = new Subject(); + + constructor( + private formBuilder: FormBuilder, + private environmentService: EnvironmentService, + ) {} + + async ngOnInit() { + await this.initSelectedRegionAndListenForEnvChanges(); + this.listenForSelectedRegionChanges(); + } + + private async initSelectedRegionAndListenForEnvChanges() { + this.environmentService.environment$ + .pipe( + map((env: Environment) => { + const region: Region = env.getRegion(); + const regionConfig: RegionConfig = this.availableRegionConfigs.find( + (availableRegionConfig) => availableRegionConfig.key === region, + ); + + if (regionConfig === undefined) { + // Self hosted does not have a region config. + return Region.SelfHosted; + } + + return regionConfig; + }), + tap((selectedRegionInitialValue: RegionConfig | Region.SelfHosted) => { + // This inits the form control with the selected region, but + // it also sets the value to self hosted if the self hosted settings are saved successfully + // in the client specific implementation managed by the parent component. + // It also resets the value to the previously selected region if the self hosted + // settings are closed without saving. We don't emit the event to avoid a loop. + this.selectedRegion.setValue(selectedRegionInitialValue, { emitEvent: false }); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + private listenForSelectedRegionChanges() { + this.selectedRegion.valueChanges + .pipe( + switchMap((selectedRegionConfig: RegionConfig | Region.SelfHosted | null) => { + if (selectedRegionConfig === null) { + return of(null); + } + + if (selectedRegionConfig === Region.SelfHosted) { + this.onOpenSelfHostedSettings.emit(); + return EMPTY; + } + + return from(this.environmentService.setEnvironment(selectedRegionConfig.key)); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.html b/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.html new file mode 100644 index 0000000000..00bed13d3a --- /dev/null +++ b/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.html @@ -0,0 +1,3 @@ +{{ "alreadyHaveAccount" | i18n }} {{ "logIn" | i18n }} diff --git a/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts new file mode 100644 index 0000000000..d28214bd57 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-start/registration-start-secondary.component.ts @@ -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-start-secondary", + templateUrl: "./registration-start-secondary.component.html", + imports: [CommonModule, JslibModule, RouterModule], +}) +export class RegistrationStartSecondaryComponent { + constructor() {} +} diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.html b/libs/auth/src/angular/registration/registration-start/registration-start.component.html new file mode 100644 index 0000000000..8f64232f9c --- /dev/null +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.html @@ -0,0 +1,83 @@ + +
+ + {{ "emailAddress" | i18n }} + + + + + {{ "name" | i18n }} + + + + + + + {{ "acceptPolicies" | i18n }} + {{ "termsOfService" | i18n }}, + {{ "privacyPolicy" | i18n }} + + + + + +
+ +
+ + +

+ {{ "checkYourEmail" | i18n }} +

+ + + +

+ {{ "noEmail" | i18n }} + + {{ "goBack" | i18n }} + + {{ "toEditYourEmailAddress" | i18n }} +

+
+
diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts new file mode 100644 index 0000000000..ac7d41038f --- /dev/null +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -0,0 +1,146 @@ +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 { Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + IconModule, + LinkModule, +} from "@bitwarden/components"; + +import { RegistrationCheckEmailIcon } from "../../icons/registration-check-email.icon"; + +export enum RegistrationStartState { + USER_DATA_ENTRY = "UserDataEntry", + CHECK_EMAIL = "CheckEmail", +} + +@Component({ + standalone: true, + selector: "auth-registration-start", + templateUrl: "./registration-start.component.html", + imports: [ + CommonModule, + ReactiveFormsModule, + JslibModule, + FormFieldModule, + AsyncActionsModule, + CheckboxModule, + ButtonModule, + LinkModule, + IconModule, + ], +}) +export class RegistrationStartComponent implements OnInit, OnDestroy { + @Output() registrationStartStateChange = new EventEmitter(); + + state: RegistrationStartState = RegistrationStartState.USER_DATA_ENTRY; + RegistrationStartState = RegistrationStartState; + readonly Icons = { RegistrationCheckEmailIcon }; + + isSelfHost = false; + + formGroup = this.formBuilder.group({ + email: ["", [Validators.required, Validators.email]], + name: [""], + acceptPolicies: [false, [this.acceptPoliciesValidator()]], + selectedRegion: [null], + }); + + get email(): FormControl { + return this.formGroup.get("email") as FormControl; + } + + get name(): FormControl { + return this.formGroup.get("name") as FormControl; + } + + get acceptPolicies(): FormControl { + return this.formGroup.get("acceptPolicies") as FormControl; + } + + emailReadonly: boolean = false; + + showErrorSummary = false; + + private destroy$ = new Subject(); + + constructor( + private formBuilder: FormBuilder, + private route: ActivatedRoute, + private platformUtilsService: PlatformUtilsService, + ) { + this.isSelfHost = platformUtilsService.isSelfHost(); + } + + async ngOnInit() { + // Emit the initial state + this.registrationStartStateChange.emit(this.state); + + this.listenForQueryParamChanges(); + } + + private listenForQueryParamChanges() { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => { + if (qParams.email != null && qParams.email.indexOf("@") > -1) { + this.email?.setValue(qParams.email); + this.emailReadonly = qParams.emailReadonly === "true"; + } + }); + } + + submit = async () => { + const valid = this.validateForm(); + + if (!valid) { + return; + } + + // TODO: Implement registration logic + + this.state = RegistrationStartState.CHECK_EMAIL; + this.registrationStartStateChange.emit(this.state); + }; + + private validateForm(): boolean { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + this.showErrorSummary = true; + } + + return this.formGroup.valid; + } + + goBack() { + this.state = RegistrationStartState.USER_DATA_ENTRY; + 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(); + } +} diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.mdx b/libs/auth/src/angular/registration/registration-start/registration-start.mdx new file mode 100644 index 0000000000..312425aa35 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-start/registration-start.mdx @@ -0,0 +1,28 @@ +import { Meta, Story, Controls } from "@storybook/addon-docs"; + +import * as stories from "./registration-start.stories"; + + + +# RegistrationStart Component + +The Auth-owned RegistrationStartComponent is to be used for the first step in the new email +verification stagegated registration process. It collects 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. + +### Cloud Example + + + +### Self Hosted Example + + + +### Query Param Example + +The component accepts two query parameters: `email` and `emailReadonly`. If an email is provided, it +will be pre-filled in the email input field. If `emailReadonly` is set to `true`, the email input +field will be set to readonly. `emailReadonly` is primarily for the organization invite flow. + + diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts new file mode 100644 index 0000000000..099f086b96 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts @@ -0,0 +1,74 @@ +import { importProvidersFrom } from "@angular/core"; +import { ActivatedRoute, Params } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { of } from "rxjs"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests"; + +import { RegistrationStartComponent } from "./registration-start.component"; + +export default { + title: "Auth/Registration/Registration Start", + component: RegistrationStartComponent, +} as Meta; + +const decorators = (options: { isSelfHost: boolean; queryParams: Params }) => { + return [ + moduleMetadata({ + imports: [RouterTestingModule], + providers: [ + { + provide: ActivatedRoute, + useValue: { queryParams: of(options.queryParams) }, + }, + { + provide: PlatformUtilsService, + useValue: { + isSelfHost: () => options.isSelfHost, + } as Partial, + }, + ], + }), + applicationConfig({ + providers: [importProvidersFrom(PreloadedEnglishI18nModule)], + }), + ]; +}; + +type Story = StoryObj; + +export const CloudExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ isSelfHost: false, queryParams: {} }), +}; + +export const SelfHostExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ isSelfHost: true, queryParams: {} }), +}; + +export const QueryParamsExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + isSelfHost: false, + queryParams: { email: "jaredWasHere@bitwarden.com", emailReadonly: "true" }, + }), +}; diff --git a/libs/shared/tsconfig.libs.json b/libs/shared/tsconfig.libs.json index 713d34a10e..452a565c9e 100644 --- a/libs/shared/tsconfig.libs.json +++ b/libs/shared/tsconfig.libs.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig", "compilerOptions": { + "resolveJsonModule": true, "paths": { "@bitwarden/admin-console": ["../admin-console/src"], "@bitwarden/angular/*": ["../angular/src/*"],