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 @@
+
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 @@
+
+
+
+
+
+
+
+ {{ "checkYourEmail" | i18n }}
+
+
+
+ {{ "followTheLinkInTheEmailSentTo" | i18n }}
+ {{ email.value }}
+ {{ "andContinueCreatingYourAccount" | 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/*"],