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/*"],