1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 00:03:56 +00:00

refactor(emergency-access-takeover): [PM-18721][PM-21271] Integrate InputPasswordComponent in EmergencyAccessTakeoverDialogComponent (#14636)

Integrates the `InputPasswordComponent` within the `EmergencyAccessTakeoverDialogComponent`

Feature Flag: `PM16117_ChangeExistingPasswordRefactor`
This commit is contained in:
rr-bw
2025-06-24 09:41:20 -07:00
committed by GitHub
parent 67e55379d7
commit 4a06562f60
17 changed files with 871 additions and 302 deletions

View File

@@ -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<Policy[]> {
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<EncryptedString> {
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;
}
}

View File

@@ -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),

View File

@@ -0,0 +1,46 @@
<bit-dialog>
<span bitDialogTitle>
{{ "takeover" | i18n }}
<small class="tw-text-muted" *ngIf="dialogData.grantorName">{{ dialogData.grantorName }}</small>
</span>
<div bitDialogContent>
@if (initializing) {
<div class="tw-flex tw-items-center tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
} @else {
<!-- TODO: PM-22237 -->
<!-- <bit-callout type="warning">{{
"emergencyAccessLoggedOutWarning" | i18n: dialogData.grantorName
}}</bit-callout> -->
<auth-input-password
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
(isSubmitting)="handleIsSubmittingChange($event)"
></auth-input-password>
}
</div>
<ng-container bitDialogFooter>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="submitting$ | async"
(click)="handlePrimaryButtonClick()"
>
{{ "save" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -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<EmergencyAccessTakeoverDialogResultType>,
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<EmergencyAccessTakeoverDialogResultType, unknown>
>,
) => {
return dialogService.open<
EmergencyAccessTakeoverDialogResultType,
EmergencyAccessTakeoverDialogData
>(EmergencyAccessTakeoverDialogComponent, dialogConfig);
};
}

View File

@@ -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 */

View File

@@ -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!"
},