diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index 5094c0c09ab..2cc9ff8b302 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -87,13 +85,23 @@ export class EmergencyAccessService } /** - * Returns policies that apply to the grantor. + * Returns policies that apply to the grantor if the grantor is the owner of an org, otherwise returns null. * Intended for grantee. * @param id emergency access id + * + * @remarks + * The ONLY time the API call will return an array of policies is when the Grantor is the OWNER + * of an organization. In all other scenarios the server returns null. Even if the Grantor + * is the member of an org that has enforced MP policies, the server will still return null + * because in the Emergency Access Takeover process, the Grantor gets removed from the org upon + * takeover, and therefore the MP policies are irrelevant. + * + * The only scenario where a Grantor does NOT get removed from the org is when that Grantor is the + * OWNER of the org. In that case the server returns Grantor policies and we enforce them on the client. */ async getGrantorPolicies(id: string): Promise { const response = await this.emergencyAccessApiService.getEmergencyGrantorPolicies(id); - let policies: Policy[]; + let policies: Policy[] = []; if (response.data != null && response.data.length > 0) { policies = response.data.map((policyResponse) => new Policy(new PolicyData(policyResponse))); } @@ -299,6 +307,10 @@ export class EmergencyAccessService const encKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey, grantorUserKey); + if (encKey == null || !encKey[1].encryptedString) { + throw new Error("masterKeyEncryptedUserKey not found"); + } + const request = new EmergencyAccessPasswordRequest(); request.newMasterPasswordHash = masterKeyHash; request.key = encKey[1].encryptedString; @@ -405,6 +417,15 @@ export class EmergencyAccessService } private async encryptKey(userKey: UserKey, publicKey: Uint8Array): Promise { - return (await this.encryptService.encapsulateKeyUnsigned(userKey, publicKey)).encryptedString; + const publicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( + userKey, + publicKey, + ); + + if (publicKeyEncryptedUserKey == null || !publicKeyEncryptedUserKey.encryptedString) { + throw new Error("publicKeyEncryptedUserKey not found"); + } + + return publicKeyEncryptedUserKey.encryptedString; } } diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index 23bf0c22bc7..1d78bb7dd17 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -10,6 +10,8 @@ import { OrganizationManagementPreferencesService } from "@bitwarden/common/admi import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -34,6 +36,10 @@ import { EmergencyAccessAddEditComponent, EmergencyAccessAddEditDialogResult, } from "./emergency-access-add-edit.component"; +import { + EmergencyAccessTakeoverDialogComponent, + EmergencyAccessTakeoverDialogResultType, +} from "./takeover/emergency-access-takeover-dialog.component"; import { EmergencyAccessTakeoverComponent, EmergencyAccessTakeoverResultType, @@ -69,6 +75,7 @@ export class EmergencyAccessComponent implements OnInit { private toastService: ToastService, private apiService: ApiService, private accountService: AccountService, + private configService: ConfigService, ) { this.canAccessPremium$ = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -285,6 +292,45 @@ export class EmergencyAccessComponent implements OnInit { } takeover = async (details: GrantorEmergencyAccess) => { + const changePasswordRefactorFlag = await this.configService.getFeatureFlag( + FeatureFlag.PM16117_ChangeExistingPasswordRefactor, + ); + + if (changePasswordRefactorFlag) { + if (!details || !details.email || !details.id) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("grantorDetailsNotFound"), + }); + this.logService.error( + "Grantor details not found when attempting emergency access takeover", + ); + + return; + } + + const grantorName = this.userNamePipe.transform(details); + + const dialogRef = EmergencyAccessTakeoverDialogComponent.open(this.dialogService, { + data: { + grantorName, + grantorEmail: details.email, + emergencyAccessId: details.id, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === EmergencyAccessTakeoverDialogResultType.Done) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("passwordResetFor", grantorName), + }); + } + + return; + } + const dialogRef = EmergencyAccessTakeoverComponent.open(this.dialogService, { data: { name: this.userNamePipe.transform(details), diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.html b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.html new file mode 100644 index 00000000000..2e0a81da976 --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.html @@ -0,0 +1,46 @@ + + + {{ "takeover" | i18n }} + {{ dialogData.grantorName }} + + +
+ @if (initializing) { +
+ + {{ "loading" | i18n }} +
+ } @else { + + + + + } +
+ + + + + +
diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts new file mode 100644 index 00000000000..3ad9ce6b1fb --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover-dialog.component.ts @@ -0,0 +1,160 @@ +import { CommonModule } from "@angular/common"; +import { Component, Inject, OnInit, ViewChild } from "@angular/core"; +import { BehaviorSubject, combineLatest, firstValueFrom, map } from "rxjs"; + +import { + InputPasswordComponent, + InputPasswordFlow, + PasswordInputResult, +} from "@bitwarden/auth/angular"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + ButtonModule, + CalloutModule, + DIALOG_DATA, + DialogConfig, + DialogModule, + DialogRef, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { EmergencyAccessService } from "../../../emergency-access"; + +type EmergencyAccessTakeoverDialogData = { + grantorName: string; + grantorEmail: string; + /** Traces a unique emergency request */ + emergencyAccessId: string; +}; + +export const EmergencyAccessTakeoverDialogResultType = { + Done: "done", +} as const; + +export type EmergencyAccessTakeoverDialogResultType = + (typeof EmergencyAccessTakeoverDialogResultType)[keyof typeof EmergencyAccessTakeoverDialogResultType]; + +/** + * This component is used by a Grantee to take over emergency access of a Grantor's account + * by changing the Grantor's master password. It is displayed as a dialog when the Grantee + * clicks the "Takeover" button while on the `/settings/emergency-access` page (see `EmergencyAccessComponent`). + * + * @link https://bitwarden.com/help/emergency-access/ + */ +@Component({ + standalone: true, + selector: "auth-emergency-access-takeover-dialog", + templateUrl: "./emergency-access-takeover-dialog.component.html", + imports: [ + ButtonModule, + CalloutModule, + CommonModule, + DialogModule, + I18nPipe, + InputPasswordComponent, + ], +}) +export class EmergencyAccessTakeoverDialogComponent implements OnInit { + @ViewChild(InputPasswordComponent) + inputPasswordComponent: InputPasswordComponent | undefined = undefined; + + private parentSubmittingBehaviorSubject = new BehaviorSubject(false); + parentSubmitting$ = this.parentSubmittingBehaviorSubject.asObservable(); + + private childSubmittingBehaviorSubject = new BehaviorSubject(false); + childSubmitting$ = this.childSubmittingBehaviorSubject.asObservable(); + + submitting$ = combineLatest([this.parentSubmitting$, this.childSubmitting$]).pipe( + map(([parentIsSubmitting, childIsSubmitting]) => parentIsSubmitting || childIsSubmitting), + ); + + initializing = true; + inputPasswordFlow = InputPasswordFlow.ChangePasswordDelegation; + masterPasswordPolicyOptions?: MasterPasswordPolicyOptions; + + constructor( + @Inject(DIALOG_DATA) protected dialogData: EmergencyAccessTakeoverDialogData, + private accountService: AccountService, + private dialogRef: DialogRef, + private emergencyAccessService: EmergencyAccessService, + private i18nService: I18nService, + private logService: LogService, + private policyService: PolicyService, + private toastService: ToastService, + ) {} + + async ngOnInit() { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + const grantorPolicies = await this.emergencyAccessService.getGrantorPolicies( + this.dialogData.emergencyAccessId, + ); + + this.masterPasswordPolicyOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(activeUserId, grantorPolicies), + ); + + this.initializing = false; + } + + protected handlePrimaryButtonClick = async () => { + if (!this.inputPasswordComponent) { + throw new Error("InputPasswordComponent is not initialized"); + } + + await this.inputPasswordComponent.submit(); + }; + + protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + this.parentSubmittingBehaviorSubject.next(true); + + try { + await this.emergencyAccessService.takeover( + this.dialogData.emergencyAccessId, + passwordInputResult.newPassword, + this.dialogData.grantorEmail, + ); + } catch (e) { + this.logService.error(e); + + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("unexpectedError"), + }); + } finally { + this.parentSubmittingBehaviorSubject.next(false); + } + + this.dialogRef.close(EmergencyAccessTakeoverDialogResultType.Done); + } + + protected handleIsSubmittingChange(isSubmitting: boolean) { + this.childSubmittingBehaviorSubject.next(isSubmitting); + } + + /** + * Strongly typed helper to open an EmergencyAccessTakeoverDialogComponent + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param dialogConfig Configuration for the dialog + */ + static open = ( + dialogService: DialogService, + dialogConfig: DialogConfig< + EmergencyAccessTakeoverDialogData, + DialogRef + >, + ) => { + return dialogService.open< + EmergencyAccessTakeoverDialogResultType, + EmergencyAccessTakeoverDialogData + >(EmergencyAccessTakeoverDialogComponent, dialogConfig); + }; +} diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 215035a0d16..d730d7db775 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -52,7 +52,7 @@ export type InitiationPath = export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; - inputPasswordFlow = InputPasswordFlow.AccountRegistration; + inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration; initializing = true; /** Password Manager or Secrets Manager */ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3885c8a110a..b450687af81 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5370,6 +5370,9 @@ "emergencyRejected": { "message": "Emergency access rejected" }, + "grantorDetailsNotFound": { + "message": "Grantor details not found" + }, "passwordResetFor": { "message": "Password reset for $USER$. You can now login using the new password.", "placeholders": { @@ -5773,12 +5776,24 @@ } } }, + "emergencyAccessLoggedOutWarning": { + "message": "Proceeding will log $NAME$ out of their current session, requiring them to log back in. Active sessions on other devices may continue to remain active for up to one hour.", + "placeholders": { + "name": { + "content": "$1", + "example": "John Smith" + } + } + }, "thisUser": { "message": "this user" }, "resetPasswordMasterPasswordPolicyInEffect": { "message": "One or more organization policies require the master password to meet the following requirements:" }, + "changePasswordDelegationMasterPasswordPolicyInEffect": { + "message": "One or more organization policies require the master password to meet the following requirements:" + }, "resetPasswordSuccess": { "message": "Password reset success!" }, diff --git a/libs/auth/src/angular/change-password/change-password.component.ts b/libs/auth/src/angular/change-password/change-password.component.ts index a3f2839d1fb..617b7ce9dd0 100644 --- a/libs/auth/src/angular/change-password/change-password.component.ts +++ b/libs/auth/src/angular/change-password/change-password.component.ts @@ -71,8 +71,11 @@ export class ChangePasswordComponent implements OnInit { throw new Error("activeAccount not found"); } - if (passwordInputResult.currentPassword == null) { - throw new Error("currentPassword not found"); + if ( + passwordInputResult.currentPassword == null || + passwordInputResult.newPasswordHint == null + ) { + throw new Error("currentPassword or newPasswordHint not found"); } await this.syncService.fullSync(true); diff --git a/libs/auth/src/angular/change-password/default-change-password.service.spec.ts b/libs/auth/src/angular/change-password/default-change-password.service.spec.ts index ab993859d70..add2e62adbc 100644 --- a/libs/auth/src/angular/change-password/default-change-password.service.spec.ts +++ b/libs/auth/src/angular/change-password/default-change-password.service.spec.ts @@ -116,7 +116,7 @@ describe("DefaultChangePasswordService", () => { // Assert await expect(testFn).rejects.toThrow( - "currentMasterKey or currentServerMasterKeyHash not found", + "invalid PasswordInputResult credentials, could not change password", ); }); @@ -130,7 +130,7 @@ describe("DefaultChangePasswordService", () => { // Assert await expect(testFn).rejects.toThrow( - "currentMasterKey or currentServerMasterKeyHash not found", + "invalid PasswordInputResult credentials, could not change password", ); }); diff --git a/libs/auth/src/angular/change-password/default-change-password.service.ts b/libs/auth/src/angular/change-password/default-change-password.service.ts index 315f979aad9..4c5f3d10d74 100644 --- a/libs/auth/src/angular/change-password/default-change-password.service.ts +++ b/libs/auth/src/angular/change-password/default-change-password.service.ts @@ -26,8 +26,14 @@ export class DefaultChangePasswordService implements ChangePasswordService { if (!userId) { throw new Error("userId not found"); } - if (!passwordInputResult.currentMasterKey || !passwordInputResult.currentServerMasterKeyHash) { - throw new Error("currentMasterKey or currentServerMasterKeyHash not found"); + if ( + !passwordInputResult.currentMasterKey || + !passwordInputResult.currentServerMasterKeyHash || + !passwordInputResult.newMasterKey || + !passwordInputResult.newServerMasterKeyHash || + passwordInputResult.newPasswordHint == null + ) { + throw new Error("invalid PasswordInputResult credentials, could not change password"); } const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( diff --git a/libs/auth/src/angular/input-password/input-password.component.html b/libs/auth/src/angular/input-password/input-password.component.html index 8955a7b40b1..b5a9f5a56e9 100644 --- a/libs/auth/src/angular/input-password/input-password.component.html +++ b/libs/auth/src/angular/input-password/input-password.component.html @@ -1,6 +1,11 @@
@@ -35,6 +40,22 @@ type="password" formControlName="newPassword" /> + + - + {{ "important" | i18n }} {{ "masterPassImportant" | i18n }} {{ minPasswordLengthMsg }}. @@ -74,26 +95,32 @@ > - - {{ "masterPassHintLabel" | i18n }} - - - {{ - "masterPassHintText" - | i18n: formGroup.value.newPasswordHint.length : maxHintLength.toString() - }} - - + + + {{ "masterPassHintLabel" | i18n }} + + + {{ + "masterPassHintText" + | i18n: formGroup.value.newPasswordHint.length.toString() : maxHintLength.toString() + }} + + - - - {{ "checkForBreaches" | i18n }} - + + + {{ "checkForBreaches" | i18n }} + + -
+
diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 49fe03ff855..79157cae901 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from "@angular/forms"; import { firstValueFrom } from "rxjs"; @@ -13,6 +13,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; @@ -30,6 +31,7 @@ import { ToastService, Translation, } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { DEFAULT_KDF_CONFIG, KdfConfig, @@ -53,31 +55,46 @@ import { PasswordInputResult } from "./password-input-result"; // eslint-disable-next-line @bitwarden/platform/no-enums export enum InputPasswordFlow { /** - * Form elements displayed: - * - [Input] New password - * - [Input] New password confirm - * - [Input] New password hint - * - [Checkbox] Check for breaches + * Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]` + * + * Note: this flow does not receive an active account `userId` as an `@Input` + */ + SetInitialPasswordAccountRegistration, + /** + * Form Fields: `[newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]` */ - AccountRegistration, // important: this flow does not involve an activeAccount/userId SetInitialPasswordAuthedUser, - /* - * All form elements above, plus: [Input] Current password (as the first element in the UI) + /** + * Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches]` */ ChangePassword, /** - * All form elements above, plus: [Checkbox] Rotate account encryption key (as the last element in the UI) + * Form Fields: `[currentPassword, newPassword, newPasswordConfirm, newPasswordHint, checkForBreaches, rotateUserKey]` */ ChangePasswordWithOptionalUserKeyRotation, + /** + * This flow is used when a user changes the password for another user's account, such as: + * - Emergency Access Takeover + * - Account Recovery + * + * Since both of those processes use a dialog, the `InputPasswordComponent` will not display + * buttons for `ChangePasswordDelegation` because the dialog will have its own buttons. + * + * Form Fields: `[newPassword, newPasswordConfirm]` + * + * Note: this flow does not receive an active account `userId` or `email` as `@Input`s + */ + ChangePasswordDelegation, } interface InputPasswordForm { + currentPassword?: FormControl; + newPassword: FormControl; newPasswordConfirm: FormControl; - newPasswordHint: FormControl; - checkForBreaches: FormControl; + newPasswordHint?: FormControl; - currentPassword?: FormControl; + checkForBreaches?: FormControl; rotateUserKey?: FormControl; } @@ -91,20 +108,25 @@ interface InputPasswordForm { FormFieldModule, IconButtonModule, InputModule, - ReactiveFormsModule, - SharedModule, + JslibModule, PasswordCalloutComponent, PasswordStrengthV2Component, - JslibModule, + ReactiveFormsModule, + SharedModule, ], }) export class InputPasswordComponent implements OnInit { + @ViewChild(PasswordStrengthV2Component) passwordStrengthComponent: + | PasswordStrengthV2Component + | undefined = undefined; + @Output() onPasswordFormSubmit = new EventEmitter(); @Output() onSecondaryButtonClick = new EventEmitter(); + @Output() isSubmitting = new EventEmitter(); @Input({ required: true }) flow!: InputPasswordFlow; - @Input({ required: true, transform: (val: string) => val.trim().toLowerCase() }) email!: string; + @Input({ transform: (val: string) => val?.trim().toLowerCase() }) email?: string; @Input() userId?: UserId; @Input() loading = false; @Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null; @@ -132,11 +154,6 @@ export class InputPasswordComponent implements OnInit { Validators.minLength(this.minPasswordLength), ]), newPasswordConfirm: this.formBuilder.nonNullable.control("", Validators.required), - newPasswordHint: this.formBuilder.nonNullable.control("", [ - Validators.minLength(this.minHintLength), - Validators.maxLength(this.maxHintLength), - ]), - checkForBreaches: this.formBuilder.nonNullable.control(true), }, { validators: [ @@ -146,12 +163,6 @@ export class InputPasswordComponent implements OnInit { "newPasswordConfirm", this.i18nService.t("masterPassDoesntMatch"), ), - compareInputs( - ValidationGoal.InputsShouldNotMatch, - "newPassword", - "newPasswordHint", - this.i18nService.t("hintEqualsPassword"), - ), ], }, ); @@ -176,9 +187,11 @@ export class InputPasswordComponent implements OnInit { private kdfConfigService: KdfConfigService, private keyService: KeyService, private masterPasswordService: MasterPasswordServiceAbstraction, + private passwordGenerationService: PasswordGenerationServiceAbstraction, private platformUtilsService: PlatformUtilsService, private policyService: PolicyService, private toastService: ToastService, + private validationService: ValidationService, ) {} ngOnInit(): void { @@ -187,6 +200,27 @@ export class InputPasswordComponent implements OnInit { } private addFormFieldsIfNecessary() { + if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) { + this.formGroup.addControl( + "newPasswordHint", + this.formBuilder.nonNullable.control("", [ + Validators.minLength(this.minHintLength), + Validators.maxLength(this.maxHintLength), + ]), + ); + + this.formGroup.addValidators([ + compareInputs( + ValidationGoal.InputsShouldNotMatch, + "newPassword", + "newPasswordHint", + this.i18nService.t("hintEqualsPassword"), + ), + ]); + + this.formGroup.addControl("checkForBreaches", this.formBuilder.nonNullable.control(true)); + } + if ( this.flow === InputPasswordFlow.ChangePassword || this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation @@ -227,160 +261,201 @@ export class InputPasswordComponent implements OnInit { } } - protected submit = async () => { - this.verifyFlowAndUserId(); + submit = async () => { + try { + this.isSubmitting.emit(true); - this.formGroup.markAllAsTouched(); + this.verifyFlow(); - if (this.formGroup.invalid) { - this.showErrorSummary = true; - return; - } + this.formGroup.markAllAsTouched(); - if (!this.email) { - throw new Error("Email is required to create master key."); - } - - const currentPassword = this.formGroup.controls.currentPassword?.value ?? ""; - const newPassword = this.formGroup.controls.newPassword.value; - const newPasswordHint = this.formGroup.controls.newPasswordHint.value; - const checkForBreaches = this.formGroup.controls.checkForBreaches.value; - - // 1. Determine kdfConfig - if (this.flow === InputPasswordFlow.AccountRegistration) { - this.kdfConfig = DEFAULT_KDF_CONFIG; - } else { - if (!this.userId) { - throw new Error("userId not passed down"); - } - this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId)); - } - - if (this.kdfConfig == null) { - throw new Error("KdfConfig is required to create master key."); - } - - // 2. Verify current password is correct (if necessary) - if ( - this.flow === InputPasswordFlow.ChangePassword || - this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation - ) { - const currentPasswordVerified = await this.verifyCurrentPassword( - currentPassword, - this.kdfConfig, - ); - if (!currentPasswordVerified) { + if (this.formGroup.invalid) { + this.showErrorSummary = true; return; } + + const currentPassword = this.formGroup.controls.currentPassword?.value ?? ""; + const newPassword = this.formGroup.controls.newPassword.value; + const newPasswordHint = this.formGroup.controls.newPasswordHint?.value ?? ""; + const checkForBreaches = this.formGroup.controls.checkForBreaches?.value ?? true; + + if (this.flow === InputPasswordFlow.ChangePasswordDelegation) { + await this.handleChangePasswordDelegationFlow(newPassword); + return; + } + + if (!this.email) { + throw new Error("Email is required to create master key."); + } + + // 1. Determine kdfConfig + if (this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration) { + this.kdfConfig = DEFAULT_KDF_CONFIG; + } else { + if (!this.userId) { + throw new Error("userId not passed down"); + } + this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId)); + } + + if (this.kdfConfig == null) { + throw new Error("KdfConfig is required to create master key."); + } + + // 2. Verify current password is correct (if necessary) + if ( + this.flow === InputPasswordFlow.ChangePassword || + this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation + ) { + const currentPasswordVerified = await this.verifyCurrentPassword( + currentPassword, + this.kdfConfig, + ); + if (!currentPasswordVerified) { + return; + } + } + + // 3. Verify new password + const newPasswordVerified = await this.verifyNewPassword( + newPassword, + this.passwordStrengthScore, + checkForBreaches, + ); + if (!newPasswordVerified) { + return; + } + + // 4. Create cryptographic keys and build a PasswordInputResult object + const newMasterKey = await this.keyService.makeMasterKey( + newPassword, + this.email, + this.kdfConfig, + ); + + const newServerMasterKeyHash = await this.keyService.hashMasterKey( + newPassword, + newMasterKey, + HashPurpose.ServerAuthorization, + ); + + const newLocalMasterKeyHash = await this.keyService.hashMasterKey( + newPassword, + newMasterKey, + HashPurpose.LocalAuthorization, + ); + + const passwordInputResult: PasswordInputResult = { + newPassword, + newMasterKey, + newServerMasterKeyHash, + newLocalMasterKeyHash, + newPasswordHint, + kdfConfig: this.kdfConfig, + }; + + if ( + this.flow === InputPasswordFlow.ChangePassword || + this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation + ) { + const currentMasterKey = await this.keyService.makeMasterKey( + currentPassword, + this.email, + this.kdfConfig, + ); + + const currentServerMasterKeyHash = await this.keyService.hashMasterKey( + currentPassword, + currentMasterKey, + HashPurpose.ServerAuthorization, + ); + + const currentLocalMasterKeyHash = await this.keyService.hashMasterKey( + currentPassword, + currentMasterKey, + HashPurpose.LocalAuthorization, + ); + + passwordInputResult.currentPassword = currentPassword; + passwordInputResult.currentMasterKey = currentMasterKey; + passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash; + passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash; + } + + if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) { + passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value; + } + + // 5. Emit cryptographic keys and other password related properties + this.onPasswordFormSubmit.emit(passwordInputResult); + } catch (e) { + this.validationService.showError(e); + } finally { + this.isSubmitting.emit(false); + } + }; + + /** + * We cannot mark the `userId` or `email` `@Input`s as required because some flows + * require them, and some do not. This method enforces that: + * - Certain flows MUST have a `userId` and/or `email` passed down + * - Certain flows must NOT have a `userId` and/or `email` passed down + */ + private verifyFlow() { + /** UserId checks */ + + // These flows require that an active account userId must NOT be passed down + if ( + this.flow === InputPasswordFlow.SetInitialPasswordAccountRegistration || + this.flow === InputPasswordFlow.ChangePasswordDelegation + ) { + if (this.userId) { + throw new Error("There should be no active account userId passed down in a this flow."); + } } - // 3. Verify new password + // All other flows require that an active account userId MUST be passed down + if ( + this.flow !== InputPasswordFlow.SetInitialPasswordAccountRegistration && + this.flow !== InputPasswordFlow.ChangePasswordDelegation + ) { + if (!this.userId) { + throw new Error("This flow requires that an active account userId be passed down."); + } + } + + /** Email checks */ + + // This flow requires that an email must NOT be passed down + if (this.flow === InputPasswordFlow.ChangePasswordDelegation) { + if (this.email) { + throw new Error("There should be no email passed down in this flow."); + } + } + + // All other flows require that an email MUST be passed down + if (this.flow !== InputPasswordFlow.ChangePasswordDelegation) { + if (!this.email) { + throw new Error("This flow requires that an email be passed down."); + } + } + } + + private async handleChangePasswordDelegationFlow(newPassword: string) { const newPasswordVerified = await this.verifyNewPassword( newPassword, this.passwordStrengthScore, - checkForBreaches, + false, ); if (!newPasswordVerified) { return; } - // 4. Create cryptographic keys and build a PasswordInputResult object - const newMasterKey = await this.keyService.makeMasterKey( - newPassword, - this.email, - this.kdfConfig, - ); - - const newServerMasterKeyHash = await this.keyService.hashMasterKey( - newPassword, - newMasterKey, - HashPurpose.ServerAuthorization, - ); - - const newLocalMasterKeyHash = await this.keyService.hashMasterKey( - newPassword, - newMasterKey, - HashPurpose.LocalAuthorization, - ); - const passwordInputResult: PasswordInputResult = { newPassword, - newMasterKey, - newServerMasterKeyHash, - newLocalMasterKeyHash, - newPasswordHint, - kdfConfig: this.kdfConfig, }; - if ( - this.flow === InputPasswordFlow.ChangePassword || - this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation - ) { - const currentMasterKey = await this.keyService.makeMasterKey( - currentPassword, - this.email, - this.kdfConfig, - ); - - const currentServerMasterKeyHash = await this.keyService.hashMasterKey( - currentPassword, - currentMasterKey, - HashPurpose.ServerAuthorization, - ); - - const currentLocalMasterKeyHash = await this.keyService.hashMasterKey( - currentPassword, - currentMasterKey, - HashPurpose.LocalAuthorization, - ); - - passwordInputResult.currentPassword = currentPassword; - passwordInputResult.currentMasterKey = currentMasterKey; - passwordInputResult.currentServerMasterKeyHash = currentServerMasterKeyHash; - passwordInputResult.currentLocalMasterKeyHash = currentLocalMasterKeyHash; - } - - if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) { - passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value; - } - - // 5. Emit cryptographic keys and other password related properties this.onPasswordFormSubmit.emit(passwordInputResult); - }; - - /** - * This method prevents a dev from passing down the wrong `InputPasswordFlow` - * from the parent component or from failing to pass down a `userId` for flows - * that require it. - * - * We cannot mark the `userId` `@Input` as required because in an account registration - * flow we will not have an active account `userId` to pass down. - */ - private verifyFlowAndUserId() { - /** - * There can be no active account (and thus no userId) in an account registration - * flow. If there is a userId, it means the dev passed down the wrong InputPasswordFlow - * from the parent component. - */ - if (this.flow === InputPasswordFlow.AccountRegistration) { - if (this.userId) { - throw new Error( - "There can be no userId in an account registration flow. Please pass down the appropriate InputPasswordFlow from the parent component.", - ); - } - } - - /** - * There MUST be an active account (and thus a userId) in all other flows. - * If no userId is passed down, it means the dev either: - * (a) passed down the wrong InputPasswordFlow, or - * (b) passed down the correct InputPasswordFlow but failed to pass down a userId - */ - if (this.flow !== InputPasswordFlow.AccountRegistration) { - if (!this.userId) { - throw new Error("The selected InputPasswordFlow requires that a userId be passed down"); - } - } } /** @@ -391,16 +466,19 @@ export class InputPasswordComponent implements OnInit { currentPassword: string, kdfConfig: KdfConfig, ): Promise { + if (!this.email) { + throw new Error("Email is required to verify current password."); + } + if (!this.userId) { + throw new Error("userId is required to verify current password."); + } + const currentMasterKey = await this.keyService.makeMasterKey( currentPassword, this.email, kdfConfig, ); - if (!this.userId) { - throw new Error("userId not passed down"); - } - const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( currentMasterKey, this.userId, @@ -549,4 +627,33 @@ export class InputPasswordComponent implements OnInit { protected getPasswordStrengthScore(score: PasswordStrengthScore) { this.passwordStrengthScore = score; } + + protected async generatePassword() { + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; + this.formGroup.patchValue({ + newPassword: await this.passwordGenerationService.generatePassword(options), + }); + + if (!this.passwordStrengthComponent) { + throw new Error("PasswordStrengthComponent is not initialized"); + } + + this.passwordStrengthComponent.updatePasswordStrength( + this.formGroup.controls.newPassword.value, + ); + } + + protected copy() { + const value = this.formGroup.value.newPassword; + if (value == null) { + return; + } + + this.platformUtilsService.copyToClipboard(value, { window: window }); + this.toastService.showToast({ + variant: "info", + title: "", + message: this.i18nService.t("valueCopied", this.i18nService.t("password")), + }); + } } diff --git a/libs/auth/src/angular/input-password/input-password.mdx b/libs/auth/src/angular/input-password/input-password.mdx index b0b05a810ba..e272044a215 100644 --- a/libs/auth/src/angular/input-password/input-password.mdx +++ b/libs/auth/src/angular/input-password/input-password.mdx @@ -6,14 +6,20 @@ import * as stories from "./input-password.stories.ts"; # InputPassword Component -The `InputPasswordComponent` allows a user to enter master password related credentials. On form -submission, the component creates cryptographic properties (`newMasterKey`, -`newServerMasterKeyHash`, etc.) and emits those properties to the parent (along with the other -values defined in `PasswordInputResult`). +The `InputPasswordComponent` allows a user to enter master password related credentials. +Specifically, it does the following: -The component is intended for re-use in different scenarios throughout the application. Therefore it -is mostly presentational and simply emits values rather than acting on them itself. It is the job of -the parent component to act on those values as needed. +1. Displays form fields in the UI +2. Validates form fields +3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`, + `newServerMasterKeyHash`, etc.) +4. Emits the generated properties to the parent component + +The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our +form UI and validation logic consistent. As such, it is intended for re-use in different set/change +password scenarios throughout the Bitwarden application. It is mostly presentational and simply +emits values rather than acting on them itself. It is the job of the parent component to act on +those values as needed.
@@ -22,11 +28,13 @@ the parent component to act on those values as needed. - [@Inputs](#inputs) - [@Outputs](#outputs) - [The InputPasswordFlow](#the-inputpasswordflow) + - [Use Cases](#use-cases) - [HTML - Form Fields](#html---form-fields) - [TypeScript - Credential Generation](#typescript---credential-generation) - - [Difference between AccountRegistration and SetInitialPasswordAuthedUser](#difference-between-accountregistration-and-setinitialpasswordautheduser) + - [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser) - [Validation](#validation) - [Submit Logic](#submit-logic) +- [Submitting From a Parent Dialog Component](#submitting-from-a-parent-dialog-component) - [Example](#example)
@@ -37,12 +45,24 @@ the parent component to act on those values as needed. - `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine which form input elements will be displayed in the UI and which cryptographic keys will be created - and emitted. -- `email` - the parent component must provide an email so that the `InputPasswordComponent` can - create a master key. + and emitted. [Click here](#the-inputpasswordflow) to learn more about the different + `InputPasswordFlow` options. + +**Optional (sometimes)** + +These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` +are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that +the `email` and/or `userId` is present in certain flows, while not present in other flows. + +- `email` - allows the `InputPasswordComponent` to generate a master key +- `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`, + verify that a current password is correct, and perform validation prior to user key rotation on + the parent **Optional** +These `@Inputs` are truly optional. + - `loading` - a boolean used to indicate that the parent component is performing some long-running/async operation and that the form should be disabled until the operation is complete. The primary button will also show a spinner if `loading` is true. @@ -57,6 +77,7 @@ the parent component to act on those values as needed. ## `@Output()`'s - `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object + ([see more below](#submit-logic)). - `onSecondaryButtonClick` - on click, emits a notice that the secondary button has been clicked. The parent component can listen for this event and take some custom action as needed (go back, cancel, logout, etc.) @@ -66,79 +87,100 @@ the parent component to act on those values as needed. ## The `InputPasswordFlow` The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the -credential generation logic of the component. +credential generation logic of the component. It is important for the dev to understand when to use +each flow. -
+### Use Cases + +**`SetInitialPasswordAccountRegistration`** + +Used in scenarios where we have no existing user, and thus NO active account `userId`: + +- Standard Account Registration +- Email Invite Account Registration +- Trial Initiation Account registration

+ +**`SetInitialPasswordAuthedUser`** + +Used in scenarios where we do have an existing and authed user, and thus an active account `userId`: + +- A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set + their initial password +- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a + starting role that requires them to have/set their initial password + - A note on JIT provisioned user flows: + - Even though a JIT provisioned user is a brand-new user who was “just” created, we consider + them to be an “existing authed user” _from the perspective of the set-password flow_. This is + because at the time they set their initial password, their account already exists in the + database (before setting their password) and they have already authenticated via SSO. + - The same is not true in the account registration flows above—that is, during account + registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their + initial password, their account does not yet exist in the database, and will only be created + once they set an initial password. +- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now + requires them to have/set their initial password +- An existing user logs in after their org admin offboarded the org from TDE, and the user must now + have/set their initial password

+ +**`ChangePassword`** + +Used in scenarios where we simply want to offer the user the ability to change their password: + +- User clicks an org email invite link an logs in with their password which does not meet the org's + policy requirements +- User logs in with password that does not meet the org's policy requirements +- User logs in after their password was reset via Account Recovery (and now they must change their + password)

+ +**`ChangePasswordWithOptionalUserKeyRotation`** + +Used in scenarios where we want to offer users the additional option of rotating their user key: + +- Account Settings (Web) - change password screen + +Note that the user key rotation itself does not happen on the `InputPasswordComponent`, but rather +on the parent component. The `InputPasswordComponent` simply emits a boolean value that indicates +whether or not the user key should be rotated.

+ +**`ChangePasswordDelegation`** + +Used in scenarios where one user changes the password for another user's account: + +- Emergency Access Takeover +- Account Recovery

### HTML - Form Fields -The `InputPasswordFlow` determines which form fields get displayed in the UI. - -**`InputPasswordFlow.AccountRegistration`** and **`InputPasswordFlow.SetInitialPasswordAuthedUser`** - -- Input: New password -- Input: Confirm new password -- Input: Hint -- Checkbox: Check for breaches - -**`InputPasswordFlow.ChangePassword`** - -Includes everything above, plus: - -- Input: Current password (as the first element in the UI) - -**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`** - -Includes everything above, plus: - -- Checkbox: Rotate account encryption key (as the last element in the UI) +Click through the individual Stories in Storybook to see how the `InputPassswordFlow` determines +which form field UI elements get displayed.
### TypeScript - Credential Generation -- The `AccountRegistration` and `SetInitialPasswordAuthedUser` flows involve a user setting their - password for the first time. Therefore on submit the component will only generate new credentials - (`newMasterKey`) and not current credentials (`currentMasterKey`). -- The `ChangePassword` and `ChangePasswordWithOptionalUserKeyRotation` flows both require the user - to enter a current password along with a new password. Therefore on submit the component will - generate current credentials (`currentMasterKey`) along with new credentials (`newMasterKey`). +- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`** + - These flows involve a user setting their password for the first time. Therefore on submit the + component will only generate new credentials (`newMasterKey`) and not current credentials + (`currentMasterKey`).

+- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`** + - These flows both require the user to enter a current password along with a new password. + Therefore on submit the component will generate current credentials (`currentMasterKey`) along + with new credentials (`newMasterKey`).

+- **`ChangePasswordDelegation`** + - This flow does not generate any credentials, but simply validates the new password and emits it + up to the parent.
-### Difference between `AccountRegistration` and `SetInitialPasswordAuthedUser` +### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser` These two flows are similar in that they display the same form fields and only generate new credentials, but we need to keep them separate for the following reasons: -- `AccountRegistration` involves scenarios where we have no existing user, and **thus NO active - account `userId`**: - - - Standard Account Registration - - Email Invite Account Registration - - Trial Initiation Account Registration - -
- +- `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and + **thus NO active account `userId`**: - `SetInitialPasswordAuthedUser` involves scenarios where we do have an existing and authed user, and **thus an active account `userId`**: - - A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set - their initial password - - A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a - starting role that requires them to have/set their initial password - - A note on JIT provisioned user flows: - - Even though a JIT provisioned user is a brand-new user who was “just” created, we consider - them to be an “existing authed user” _from the perspective of the set-password flow_. This - is because at the time they set their initial password, their account already exists in the - database (before setting their password) and they have already authenticated via SSO. - - The same is not true in the account registration flows above—that is, during account - registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set - their initial password, their account does not yet exist in the database, and will only be - created once they set an initial password. - - An existing user in a TDE org logs in after the org admin upgraded the user to a role that now - requires them to have/set their initial password - - An existing user logs in after their org admin offboarded the org from TDE, and the user must - now have/set their initial password The presence or absence of an active account `userId` is important because it determines how we get the correct `kdfConfig` prior to key generation: @@ -148,12 +190,12 @@ the correct `kdfConfig` prior to key generation: `userId` That said, we cannot mark the `userId` as a required via `@Input({ required: true })` because -`AccountRegistration` flows will not have a `userId`. But we still want to require a `userId` in a -`SetInitialPasswordAuthedUser` flow. Therefore the `InputPasswordComponent` has init logic that -ensures the following: +`SetInitialPasswordAccountRegistration` flows will not have a `userId`. But we still want to require +a `userId` in a `SetInitialPasswordAuthedUser` flow. Therefore the `InputPasswordComponent` has init +logic that ensures the following: -- If the passed down flow is `AccountRegistration`, require that the parent **MUST NOT** have passed - down a `userId` +- If the passed down flow is `SetInitialPasswordAccountRegistration`, require that the parent **MUST + NOT** have passed down a `userId` - If the passed down flow is `SetInitialPasswordAuthedUser` require that the parent must also have passed down a `userId` @@ -169,11 +211,6 @@ Form validators ensure that: - The new password and confirmed new password are the same - The new password and password hint are NOT the same -Additional submit logic validation ensures that: - -- The new password adheres to any enforced master password policy options (that were passed down - from the parent) -
## Submit Logic @@ -182,9 +219,10 @@ When the form is submitted, the `InputPasswordComponent` does the following in o 1. Verifies inputs: - Checks that the current password is correct (if it was required in the flow) - - Checks if the new password is found in a breach and warns the user if so (if the user selected - the checkbox) - - Checks that the new password meets any master password policy requirements enforced by an org + - Checks that the new password is not weak or found in any breaches (if the user selected the + checkbox) + - Checks that the new password adheres to any enforced master password policies that were + optionally passed down by the parent 2. Uses the form inputs to create cryptographic properties (`newMasterKey`, `newServerMasterKeyHash`, etc.) 3. Emits those cryptographic properties up to the parent (along with other values defined in @@ -192,23 +230,83 @@ When the form is submitted, the `InputPasswordComponent` does the following in o ```typescript export interface PasswordInputResult { - // Properties starting with "current..." are included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation currentPassword?: string; currentMasterKey?: MasterKey; currentServerMasterKeyHash?: string; currentLocalMasterKeyHash?: string; newPassword: string; - newPasswordHint: string; - newMasterKey: MasterKey; - newServerMasterKeyHash: string; - newLocalMasterKeyHash: string; + newPasswordHint?: string; + newMasterKey?: MasterKey; + newServerMasterKeyHash?: string; + newLocalMasterKeyHash?: string; - kdfConfig: KdfConfig; - rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation + kdfConfig?: KdfConfig; + rotateUserKey?: boolean; } ``` +## Submitting From a Parent Dialog Component + +Some of our set/change password flows use dialogs, such as Emergency Access Takeover and Account +Recovery. These are covered by the `ChangePasswordDelegation` flow. Because dialogs have their own +buttons, we don't want to display an additional Submit button in the `InputPasswordComponent` when +embedded in a dialog. + +Therefore we do the following: + +- The `InputPasswordComponent` hides the button in the UI and exposes its `submit()` method as a + public method. +- The parent dialog component can then access this method via `@ViewChild()`. +- When the user clicks the primary button on the parent dialog, we call the `submit()` method on the + `InputPasswordComponent`. + +```html + + + + + +
+ +
+ + + + + +
+``` + +```typescript +// emergency-access-takeover-dialog.component.ts + +export class EmergencyAccessTakeoverDialogComponent implements OnInit { + @ViewChild(InputPasswordComponent) + inputPasswordComponent: InputPasswordComponent; + + // ... + + handlePrimaryButtonClick = async () => { + await this.inputPasswordComponent.submit(); + }; + + async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) { + // ... run logic that handles the `PasswordInputResult` object emission + } +} +``` + +
+ # Example **`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`** diff --git a/libs/auth/src/angular/input-password/input-password.stories.ts b/libs/auth/src/angular/input-password/input-password.stories.ts index 708b74b9925..4ffd0e202ee 100644 --- a/libs/auth/src/angular/input-password/input-password.stories.ts +++ b/libs/auth/src/angular/input-password/input-password.stories.ts @@ -11,12 +11,14 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; // FIXME: remove `/apps` import from `/libs` @@ -73,6 +75,7 @@ export default { provide: PlatformUtilsService, useValue: { launchUri: () => Promise.resolve(true), + copyToClipboard: () => true, }, }, { @@ -125,16 +128,31 @@ export default { showToast: action("ToastService.showToast"), } as Partial, }, + { + provide: PasswordGenerationServiceAbstraction, + useValue: { + getOptions: () => ({}), + generatePassword: () => "generated-password", + }, + }, + { + provide: ValidationService, + useValue: { + showError: () => ["validation error"], + }, + }, ], }), ], args: { InputPasswordFlow: { - AccountRegistration: InputPasswordFlow.AccountRegistration, + SetInitialPasswordAccountRegistration: + InputPasswordFlow.SetInitialPasswordAccountRegistration, SetInitialPasswordAuthedUser: InputPasswordFlow.SetInitialPasswordAuthedUser, ChangePassword: InputPasswordFlow.ChangePassword, ChangePasswordWithOptionalUserKeyRotation: InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation, + ChangePasswordDelegation: InputPasswordFlow.ChangePasswordDelegation, }, userId: "1" as UserId, email: "user@email.com", @@ -154,12 +172,12 @@ export default { type Story = StoryObj; -export const AccountRegistration: Story = { +export const SetInitialPasswordAccountRegistration: Story = { render: (args) => ({ props: args, template: ` `, @@ -205,14 +223,24 @@ export const ChangePasswordWithOptionalUserKeyRotation: Story = { }), }; +export const ChangePasswordDelegation: Story = { + render: (args) => ({ + props: args, + template: ` + +
+
Note: no buttons here as this flow is expected to be used in a dialog, which will have its own buttons
+ `, + }), +}; + export const WithPolicies: Story = { render: (args) => ({ props: args, template: ` `, @@ -224,7 +252,7 @@ export const SecondaryButton: Story = { props: args, template: ` @@ -265,7 +293,7 @@ export const InlineButtons: Story = { props: args, template: ` (); - inputPasswordFlow = InputPasswordFlow.AccountRegistration; + inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration; loading = true; submitting = false; email: string; diff --git a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts index 95d54d589bc..37afa77f0d4 100644 --- a/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts +++ b/libs/auth/src/angular/set-password-jit/default-set-password-jit.service.spec.ts @@ -122,7 +122,11 @@ describe("DefaultSetPasswordJitService", () => { }; credentials = { - ...passwordInputResult, + newMasterKey: passwordInputResult.newMasterKey, + newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, + newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash, + newPasswordHint: passwordInputResult.newPasswordHint, + kdfConfig: passwordInputResult.kdfConfig, orgSsoIdentifier, orgId, resetPasswordAutoEnroll, diff --git a/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts b/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts index fa064c9367b..1a2674cd3d4 100644 --- a/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts +++ b/libs/auth/src/angular/set-password-jit/set-password-jit.component.ts @@ -97,7 +97,11 @@ export class SetPasswordJitComponent implements OnInit { this.submitting = true; const credentials: SetPasswordCredentials = { - ...passwordInputResult, + newMasterKey: passwordInputResult.newMasterKey, + newServerMasterKeyHash: passwordInputResult.newServerMasterKeyHash, + newLocalMasterKeyHash: passwordInputResult.newLocalMasterKeyHash, + newPasswordHint: passwordInputResult.newPasswordHint, + kdfConfig: passwordInputResult.kdfConfig, orgSsoIdentifier: this.orgSsoIdentifier, orgId: this.orgId, resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,