1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 00:33:44 +00:00

[PM-23246] Add unlock with master password unlock data for lock component (#16204)

* Add unlocking with MasterPasswordUnlockData for angular lock component
This commit is contained in:
Thomas Avery
2025-10-15 11:56:46 -05:00
committed by GitHub
parent 76e4870aa3
commit aa9a276591
15 changed files with 1249 additions and 129 deletions

View File

@@ -120,73 +120,87 @@
</ng-container>
<!-- MP Unlock -->
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
@if (
(unlockWithMasterPasswordUnlockDataFlag$ | async) &&
unlockOptions.masterPassword.enabled &&
activeUnlockOption === UnlockOption.MasterPassword
) {
<bit-master-password-lock
[(activeUnlockOption)]="activeUnlockOption"
[unlockOptions]="unlockOptions"
[biometricUnlockBtnText]="biometricUnlockBtnText"
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
(logOut)="logOut()"
></bit-master-password-lock>
} @else {
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
}
</ng-container>

View File

@@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
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";
@@ -91,9 +92,10 @@ describe("LockComponent", () => {
const mockLockComponentService = mock<LockComponentService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
const mockBroadcasterService = mock<BroadcasterService>();
const mockConfigService = mock<ConfigService>();
beforeEach(async () => {
jest.clearAllMocks();
jest.resetAllMocks();
// Setup default mock returns
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web);
@@ -148,6 +150,7 @@ describe("LockComponent", () => {
{ provide: LockComponentService, useValue: mockLockComponentService },
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
{ provide: BroadcasterService, useValue: mockBroadcasterService },
{ provide: ConfigService, useValue: mockConfigService },
],
})
.overrideProvider(DialogService, { useValue: mockDialogService })
@@ -358,6 +361,135 @@ describe("LockComponent", () => {
});
});
describe("successfulMasterPasswordUnlock", () => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const masterPassword = "test-password";
beforeEach(async () => {
component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$);
});
it.each([
[undefined as unknown as UserKey, undefined as unknown as string],
[null as unknown as UserKey, null as unknown as string],
[mockUserKey, undefined as unknown as string],
[mockUserKey, null as unknown as string],
[mockUserKey, ""],
[undefined as unknown as UserKey, masterPassword],
[null as unknown as UserKey, masterPassword],
])(
"logs an error and doesn't unlock when called with invalid data",
async (userKey, masterPassword) => {
await component.successfulMasterPasswordUnlock({ userKey, masterPassword });
expect(mockLogService.error).toHaveBeenCalledWith(
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
);
expect(mockKeyService.setUserKey).not.toHaveBeenCalled();
},
);
it.each([
[false, undefined, false],
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false],
[false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true],
[true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false],
[false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true],
])(
"unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service",
async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => {
mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue(
of(masterPasswordPolicyOptions),
);
const passwordStrengthResult = { score: 1 } as ZXCVBNResult;
mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult);
mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword);
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked();
expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId);
if (masterPasswordPolicyOptions?.enforceOnLogin) {
expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith(
masterPassword,
component.activeAccount!.email,
);
expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith(
passwordStrengthResult.score,
masterPassword,
masterPasswordPolicyOptions,
);
}
if (forceSetPassword) {
expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.WeakMasterPassword,
userId,
);
} else {
expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled();
}
},
);
it.each([
[true, ClientType.Browser],
[false, ClientType.Cli],
[false, ClientType.Desktop],
[false, ClientType.Web],
])(
"unlocks and navigate by url to previous url = %o when client type = %o and previous url was set",
async (shouldNavigate, clientType) => {
const previousUrl = "/test-url";
component.clientType = clientType;
mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl);
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked();
if (shouldNavigate) {
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl);
} else {
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
}
},
);
it.each([
["/tabs/current", ClientType.Browser],
[undefined, ClientType.Cli],
["vault", ClientType.Desktop],
["vault", ClientType.Web],
])(
"unlocks and navigate to success url = %o when client type = %o",
async (navigateUrl, clientType) => {
component.clientType = clientType;
mockLockComponentService.getPreviousUrl.mockReturnValue(null);
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked();
expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]);
},
);
it("unlocks and close browser extension popout on firefox extension", async () => {
component.shouldClosePopout = true;
mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension);
await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword });
assertUnlocked();
expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled();
});
function assertUnlocked(): void {
expect(mockKeyService.setUserKey).toHaveBeenCalledWith(
mockUserKey,
component.activeAccount!.id,
);
}
});
describe("unlockViaMasterPassword", () => {
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey;
const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = {

View File

@@ -29,10 +29,12 @@ import {
MasterPasswordVerificationResponse,
} from "@bitwarden/common/auth/types/verification";
import { ClientType, DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
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";
@@ -64,6 +66,8 @@ import {
UnlockOptionValue,
} from "../services/lock-component.service";
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
const BroadcasterSubscriptionId = "LockComponent";
const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
@@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
[ClientType.Browser]: "/tabs/current",
};
type AfterUnlockActions = {
passwordEvaluation?: {
masterPassword: string;
};
};
/// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible
/// Fixes safari autoprompt behavior
const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
@@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
MasterPasswordLockComponent,
],
})
export class LockComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected loading = true;
protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.UnlockWithMasterPasswordUnlockData,
);
activeAccount: Account | null = null;
clientType?: ClientType;
@@ -160,6 +175,7 @@ export class LockComponent implements OnInit, OnDestroy {
private logoutService: LogoutService,
private lockComponentService: LockComponentService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private configService: ConfigService,
// desktop deps
private broadcasterService: BroadcasterService,
) {}
@@ -379,7 +395,7 @@ export class LockComponent implements OnInit, OnDestroy {
// If user cancels biometric prompt, userKey is undefined.
if (userKey) {
await this.setUserKeyAndContinue(userKey, false);
await this.setUserKeyAndContinue(userKey);
}
this.unlockingViaBiometrics = false;
@@ -423,6 +439,7 @@ export class LockComponent implements OnInit, OnDestroy {
}
}
//TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag.
togglePassword() {
this.showPassword = !this.showPassword;
const input = document.getElementById(
@@ -498,6 +515,7 @@ export class LockComponent implements OnInit, OnDestroy {
}
}
// TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag.
private validateMasterPassword(): boolean {
if (this.formGroup?.invalid) {
this.toastService.showToast({
@@ -511,6 +529,7 @@ export class LockComponent implements OnInit, OnDestroy {
return true;
}
// TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag.
async unlockViaMasterPassword() {
if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) {
return;
@@ -568,10 +587,33 @@ export class LockComponent implements OnInit, OnDestroy {
return;
}
await this.setUserKeyAndContinue(userKey, true);
await this.setUserKeyAndContinue(userKey, {
passwordEvaluation: { masterPassword },
});
}
private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) {
async successfulMasterPasswordUnlock(event: {
userKey: UserKey;
masterPassword: string;
}): Promise<void> {
if (event.userKey == null || !event.masterPassword) {
this.logService.error(
"[LockComponent] successfulMasterPasswordUnlock called with invalid data.",
);
return;
}
await this.setUserKeyAndContinue(event.userKey, {
passwordEvaluation: {
masterPassword: event.masterPassword,
},
});
}
protected async setUserKeyAndContinue(
key: UserKey,
afterUnlockActions: AfterUnlockActions = {},
): Promise<void> {
if (this.activeAccount == null) {
throw new Error("No active user.");
}
@@ -585,10 +627,10 @@ export class LockComponent implements OnInit, OnDestroy {
// need to establish trust on the current device
await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id);
await this.doContinue(evaluatePasswordAfterUnlock);
await this.doContinue(afterUnlockActions);
}
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
private async doContinue(afterUnlockActions: AfterUnlockActions) {
if (this.activeAccount == null) {
throw new Error("No active user.");
}
@@ -596,7 +638,7 @@ export class LockComponent implements OnInit, OnDestroy {
await this.biometricStateService.resetUserPromptCancelled();
this.messagingService.send("unlocked");
if (evaluatePasswordAfterUnlock) {
if (afterUnlockActions.passwordEvaluation) {
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (userId == null) {
throw new Error("No active user.");
@@ -613,7 +655,7 @@ export class LockComponent implements OnInit, OnDestroy {
);
}
if (this.requirePasswordChange()) {
if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) {
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.WeakMasterPassword,
userId,
@@ -669,18 +711,15 @@ export class LockComponent implements OnInit, OnDestroy {
* Checks if the master password meets the enforced policy requirements
* If not, returns false
*/
private requirePasswordChange(): boolean {
private requirePasswordChange(masterPassword: string): boolean {
if (
this.enforcedMasterPasswordOptions == undefined ||
!this.enforcedMasterPasswordOptions.enforceOnLogin ||
this.formGroup == null ||
this.activeAccount == null
) {
return false;
}
const masterPassword = this.formGroup.controls.masterPassword.value;
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
masterPassword,
this.activeAccount.email,

View File

@@ -0,0 +1,55 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
@if (showBiometricsSwap()) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable()"
block
(click)="activeUnlockOption.set(UnlockOption.Biometrics)"
>
<span> {{ biometricUnlockBtnText() | i18n }}</span>
</button>
}
@if (showPinSwap()) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption.set(UnlockOption.Pin)"
>
{{ "unlockWithPin" | i18n }}
</button>
}
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
{{ "logOut" | i18n }}
</button>
</div>
</form>

View File

@@ -0,0 +1,472 @@
import { DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserKey } from "@bitwarden/common/types/key";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
ToastService,
} from "@bitwarden/components";
import { BiometricsStatus } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
import { MasterPasswordLockComponent } from "./master-password-lock.component";
describe("MasterPasswordLockComponent", () => {
let component: MasterPasswordLockComponent;
let fixture: ComponentFixture<MasterPasswordLockComponent>;
const accountService = mock<AccountService>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const i18nService = mock<I18nService>();
const toastService = mock<ToastService>();
const logService = mock<LogService>();
const mockMasterPassword = "testExample";
const activeAccount: Account = {
id: "user-id" as UserId,
email: "user@example.com",
emailVerified: true,
name: "User",
};
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const setupComponent = (
unlockOptions: Partial<UnlockOptions> = {},
biometricUnlockBtnText: string = "default",
account: Account | null = activeAccount,
) => {
const defaultOptions: UnlockOptions = {
masterPassword: { enabled: true },
pin: { enabled: false },
biometrics: {
enabled: false,
biometricsStatus: BiometricsStatus.NotEnabledLocally,
},
};
accountService.activeAccount$ = of(account);
fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions });
fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText);
fixture.detectChanges();
return {
form: fixture.debugElement.query(By.css("form")),
component,
...getFormElements(fixture.debugElement.query(By.css("form"))),
};
};
const getFormElements = (form: DebugElement) => ({
masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')),
toggleButton: form.query(By.css("button[bitPasswordInputToggle]")),
submitButton: form.query(By.css('button[type="submit"]')),
logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')),
secondaryButton: form.query(By.css('button[buttonType="secondary"]')),
});
beforeEach(async () => {
jest.clearAllMocks();
i18nService.t.mockImplementation((key: string) => key);
await TestBed.configureTestingModule({
imports: [
MasterPasswordLockComponent,
JslibModule,
ReactiveFormsModule,
ButtonModule,
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
],
providers: [
FormBuilder,
{ provide: AccountService, useValue: accountService },
{ provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService },
{ provide: I18nService, useValue: i18nService },
{ provide: ToastService, useValue: toastService },
{ provide: LogService, useValue: logService },
],
}).compileComponents();
fixture = TestBed.createComponent(MasterPasswordLockComponent);
component = fixture.componentInstance;
});
describe("form rendering", () => {
let elements: ReturnType<typeof setupComponent>;
beforeEach(() => {
elements = setupComponent();
});
it("creates form with proper structure", () => {
expect(component.formGroup).toBeDefined();
expect(component.formGroup.controls.masterPassword).toBeDefined();
});
const formElementTests = [
{
name: "master password input",
selector: "masterPasswordInput",
expectations: (el: HTMLInputElement) => {
expect(el).toMatchObject({
type: "password",
name: "masterPassword",
required: true,
});
expect(el.attributes).toHaveProperty("bitInput");
},
},
{
name: "password toggle button",
selector: "toggleButton",
expectations: (el: HTMLButtonElement) => {
expect(el.type).toBe("button");
expect(el.attributes).toHaveProperty("bitIconButton");
},
},
{
name: "unlock submit button",
selector: "submitButton",
expectations: (el: HTMLButtonElement) => {
expect(el).toMatchObject({
type: "submit",
textContent: expect.stringContaining("unlock"),
});
expect(el.attributes).toHaveProperty("bitButton");
},
},
{
name: "logout button",
selector: "logoutButton",
expectations: (el: HTMLButtonElement) => {
expect(el).toMatchObject({
type: "button",
textContent: expect.stringContaining("logOut"),
});
expect(el.attributes).toHaveProperty("bitButton");
},
},
];
test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => {
const element = elements[selector as keyof typeof elements] as DebugElement;
expect(element).toBeTruthy();
expectations(element.nativeElement);
});
const hiddenButtonTests = [
{
case: "biometrics swap button when biometrics is undefined",
setup: () =>
setupComponent(
{
pin: { enabled: false },
biometrics: {
enabled: undefined as unknown as boolean,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
},
"swapBiometrics",
),
expectHidden: true,
},
{
case: "biometrics swap button when biometrics is disabled",
setup: () => setupComponent({}, "swapBiometrics"),
expectHidden: true,
},
{
case: "PIN swap button when PIN is disabled",
setup: () => setupComponent({}),
expectHidden: true,
},
{
case: "PIN swap button when PIN is undefined",
setup: () =>
setupComponent({
pin: { enabled: undefined as unknown as boolean },
biometrics: {
enabled: undefined as unknown as boolean,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
}),
expectHidden: true,
},
];
test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => {
const { secondaryButton } = setup();
expect(!!secondaryButton).toBe(!expectHidden);
});
});
describe("password input", () => {
let setup: ReturnType<typeof setupComponent>;
beforeEach(() => {
setup = setupComponent();
});
it("should bind form input to masterPassword form control", async () => {
const input = setup.masterPasswordInput;
expect(input).toBeTruthy();
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
expect(component.formGroup).toBeTruthy();
const masterPasswordControl = component.formGroup!.get("masterPassword");
expect(masterPasswordControl).toBeTruthy();
masterPasswordControl!.setValue("test-password");
fixture.detectChanges();
const inputElement = input.nativeElement as HTMLInputElement;
expect(inputElement.value).toEqual("test-password");
});
it("should validate required master password field", async () => {
const formGroup = component.formGroup;
// Initially form should be invalid (empty required field)
expect(formGroup?.invalid).toEqual(true);
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true);
// Set a value
formGroup?.get("masterPassword")?.setValue("test-password");
expect(formGroup?.invalid).toEqual(false);
expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false);
});
it("should toggle password visibility when toggle button is clicked", async () => {
const toggleButton = setup.toggleButton;
expect(toggleButton).toBeTruthy();
expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement;
const input = setup.masterPasswordInput;
expect(input).toBeTruthy();
expect(input.nativeElement).toBeInstanceOf(HTMLInputElement);
const inputElement = input.nativeElement as HTMLInputElement;
// Initially password should be hidden
expect(inputElement.type).toEqual("password");
// Click toggle button
toggleButtonElement.click();
fixture.detectChanges();
expect(inputElement.type).toEqual("text");
// Click toggle button again
toggleButtonElement.click();
fixture.detectChanges();
expect(inputElement.type).toEqual("password");
});
});
describe("logout", () => {
it("emits logOut event when logout button is clicked", () => {
const setup = setupComponent();
let logoutEmitted = false;
component.logOut.subscribe(() => {
logoutEmitted = true;
});
expect(setup.logoutButton).toBeTruthy();
expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement);
const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement;
// Click logout button
logoutButtonElement.click();
expect(logoutEmitted).toBe(true);
});
});
describe("swap buttons", () => {
const swapButtonScenarios = [
{
name: "PIN swap button when PIN is enabled",
unlockOptions: {
pin: { enabled: true },
biometrics: {
enabled: false,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
},
expectedText: "unlockWithPin",
expectedUnlockOption: UnlockOption.Pin,
shouldShow: true,
shouldEnable: true,
},
{
name: "PIN swap button when PIN is disabled",
unlockOptions: {
pin: { enabled: false },
biometrics: {
enabled: false,
biometricsStatus: BiometricsStatus.PlatformUnsupported,
},
},
expectedText: "unlockWithPin",
expectedUnlockOption: UnlockOption.Pin,
shouldShow: false,
shouldEnable: false,
},
{
name: "biometrics swap button when biometrics status is available and enabled",
unlockOptions: {
pin: { enabled: false },
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
},
expectedText: "swapBiometrics",
expectedUnlockOption: UnlockOption.Biometrics,
shouldShow: true,
shouldEnable: true,
},
{
name: "biometrics swap button when biometrics status is available and disabled",
unlockOptions: {
pin: { enabled: false },
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
},
expectedText: "swapBiometrics",
expectedUnlockOption: UnlockOption.Biometrics,
shouldShow: true,
shouldEnable: false,
},
{
name: "biometrics swap button when biometrics biometrics status is unsupported and enabled",
unlockOptions: {
pin: { enabled: false },
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported },
},
expectedText: "swapBiometrics",
expectedUnlockOption: UnlockOption.Biometrics,
shouldShow: false,
shouldEnable: false,
},
{
name: "biometrics swap button when biometrics status is unsupported and disabled",
unlockOptions: {
pin: { enabled: false },
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported },
},
expectedText: "swapBiometrics",
expectedUnlockOption: UnlockOption.Biometrics,
shouldShow: false,
shouldEnable: false,
},
];
test.each(swapButtonScenarios)(
"renders and handles $name",
({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => {
const { secondaryButton, component } = setupComponent(unlockOptions, expectedText);
if (shouldShow) {
expect(secondaryButton).toBeTruthy();
expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText);
if (shouldEnable) {
secondaryButton.nativeElement.click();
expect(component.activeUnlockOption()).toBe(expectedUnlockOption);
} else {
expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true");
}
} else {
expect(secondaryButton).toBeFalsy();
}
},
);
});
describe("submit", () => {
test.each([null, undefined as unknown as string, ""])(
"won't unlock and show password invalid toast when master password is %s",
async (value) => {
component.formGroup.controls.masterPassword.setValue(value);
await component.submit();
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: i18nService.t("errorOccurred"),
message: i18nService.t("masterPasswordRequired"),
});
expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled();
},
);
test.each([null as unknown as Account, undefined as unknown as Account])(
"throws error when active account is %s",
async (value) => {
accountService.activeAccount$ = of(value);
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
await expect(component.submit()).rejects.toThrow("Null or undefined account");
expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled();
},
);
it("shows an error toast and logs the error when unlock with master password fails", async () => {
const customError = new Error("Specialized error message");
masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError);
accountService.activeAccount$ = of(activeAccount);
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
await component.submit();
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
mockMasterPassword,
activeAccount.id,
);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "error",
title: i18nService.t("errorOccurred"),
message: i18nService.t("invalidMasterPassword"),
});
expect(logService.error).toHaveBeenCalledWith(
"[MasterPasswordLockComponent] Failed to unlock via master password",
customError,
);
});
it("emits userKey when unlock is successful", async () => {
masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey);
accountService.activeAccount$ = of(activeAccount);
component.formGroup.controls.masterPassword.setValue(mockMasterPassword);
let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined;
component.successfulUnlock.subscribe(
(event: { userKey: UserKey; masterPassword: string }) => {
emittedEvent = event;
},
);
await component.submit();
expect(emittedEvent?.userKey).toEqual(mockUserKey);
expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword);
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
mockMasterPassword,
activeAccount.id,
);
});
});
});

View File

@@ -0,0 +1,111 @@
import { Component, computed, inject, input, model, output } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UserKey } from "@bitwarden/common/types/key";
import {
AsyncActionsModule,
ButtonModule,
FormFieldModule,
IconButtonModule,
ToastService,
} from "@bitwarden/components";
import { BiometricsStatus } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import {
UnlockOption,
UnlockOptions,
UnlockOptionValue,
} from "../../services/lock-component.service";
@Component({
selector: "bit-master-password-lock",
templateUrl: "master-password-lock.component.html",
imports: [
JslibModule,
ReactiveFormsModule,
ButtonModule,
FormFieldModule,
AsyncActionsModule,
IconButtonModule,
],
})
export class MasterPasswordLockComponent {
private readonly accountService = inject(AccountService);
private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService);
private readonly i18nService = inject(I18nService);
private readonly toastService = inject(ToastService);
private readonly logService = inject(LogService);
UnlockOption = UnlockOption;
activeUnlockOption = model.required<UnlockOptionValue>();
unlockOptions = input.required<UnlockOptions>();
biometricUnlockBtnText = input.required<string>();
showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false);
biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false);
showBiometricsSwap = computed(() => {
const status = this.unlockOptions().biometrics.biometricsStatus;
return (
status !== BiometricsStatus.PlatformUnsupported &&
status !== BiometricsStatus.NotEnabledLocally
);
});
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
logOut = output<void>();
formGroup = new FormGroup({
masterPassword: new FormControl("", {
validators: [Validators.required],
updateOn: "submit",
}),
});
submit = async () => {
this.formGroup.markAllAsTouched();
const masterPassword = this.formGroup.controls.masterPassword.value;
if (this.formGroup.invalid || !masterPassword) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return;
}
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.unlockViaMasterPassword(masterPassword, activeUserId);
};
private async unlockViaMasterPassword(
masterPassword: string,
activeUserId: UserId,
): Promise<void> {
try {
const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword(
masterPassword,
activeUserId,
);
this.successfulUnlock.emit({ userKey, masterPassword });
} catch (error) {
this.logService.error(
"[MasterPasswordLockComponent] Failed to unlock via master password",
error,
);
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidMasterPassword"),
});
}
}
}