1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 09:33:22 +00:00

Merge branch 'km/remove-legacy-biometric-protocol' into synced-unlock-state

This commit is contained in:
Bernd Schoolmann
2025-05-30 12:08:47 +02:00
committed by GitHub
332 changed files with 9764 additions and 5946 deletions

View File

@@ -65,12 +65,8 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
};
validate(): boolean {
if (this.formGroup.dirty) {
this.formGroup.markAllAsTouched();
return this.formGroup.valid;
} else {
return this.formGroup.valid;
}
this.formGroup.markAllAsTouched();
return this.formGroup.valid;
}
markAllAsTouched() {

View File

@@ -42,7 +42,7 @@ export type FormCacheOptions<TFormGroup extends FormGroup> = BaseCacheOptions<
/**
* Cache for temporary component state
*
* [Read more](./view-cache.md)
* [Read more](./README.md)
*
* #### Implementations
* - browser extension popup: used to persist UI between popup open and close

View File

@@ -27,6 +27,8 @@ import {
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
ChangePasswordService,
DefaultChangePasswordService,
} from "@bitwarden/auth/angular";
import {
AuthRequestApiService,
@@ -1546,6 +1548,15 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultCipherEncryptionService,
deps: [SdkService, LogService],
}),
safeProvider({
provide: ChangePasswordService,
useClass: DefaultChangePasswordService,
deps: [
KeyService,
MasterPasswordApiServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
],
}),
];
@NgModule({

View File

@@ -0,0 +1,20 @@
@if (initializing) {
<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>
} @else {
<auth-input-password
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[inlineButtons]="true"
[primaryButtonText]="{ key: 'changeMasterPassword' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
>
</auth-input-password>
}

View File

@@ -0,0 +1,110 @@
import { Component, Input, OnInit } from "@angular/core";
import { firstValueFrom } from "rxjs";
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 { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
InputPasswordComponent,
InputPasswordFlow,
} from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
@Component({
standalone: true,
selector: "auth-change-password",
templateUrl: "change-password.component.html",
imports: [InputPasswordComponent, I18nPipe],
})
export class ChangePasswordComponent implements OnInit {
@Input() inputPasswordFlow: InputPasswordFlow = InputPasswordFlow.ChangePassword;
activeAccount: Account | null = null;
email?: string;
userId?: UserId;
masterPasswordPolicyOptions?: MasterPasswordPolicyOptions;
initializing = true;
submitting = false;
constructor(
private accountService: AccountService,
private changePasswordService: ChangePasswordService,
private i18nService: I18nService,
private messagingService: MessagingService,
private policyService: PolicyService,
private toastService: ToastService,
private syncService: SyncService,
) {}
async ngOnInit() {
this.activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = this.activeAccount?.id;
this.email = this.activeAccount?.email;
if (!this.userId) {
throw new Error("userId not found");
}
this.masterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(this.userId),
);
this.initializing = false;
}
async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
try {
if (passwordInputResult.rotateUserKey) {
if (this.activeAccount == null) {
throw new Error("activeAccount not found");
}
if (passwordInputResult.currentPassword == null) {
throw new Error("currentPassword not found");
}
await this.syncService.fullSync(true);
await this.changePasswordService.rotateUserKeyMasterPasswordAndEncryptedData(
passwordInputResult.currentPassword,
passwordInputResult.newPassword,
this.activeAccount,
passwordInputResult.newPasswordHint,
);
} else {
if (!this.userId) {
throw new Error("userId not found");
}
await this.changePasswordService.changePassword(passwordInputResult, this.userId);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
}
} catch {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.submitting = false;
}
}
}

View File

@@ -0,0 +1,36 @@
import { PasswordInputResult } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
export abstract class ChangePasswordService {
/**
* Creates a new user key and re-encrypts all required data with it.
* - does so by calling the underlying method on the `UserKeyRotationService`
* - implemented in Web only
*
* @param currentPassword the current password
* @param newPassword the new password
* @param user the user account
* @param newPasswordHint the new password hint
* @throws if called from a non-Web client
*/
abstract rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
newPasswordHint: string,
): Promise<void>;
/**
* Changes the user's password and re-encrypts the user key with the `newMasterKey`.
* - Specifically, this method uses credentials from the `passwordInputResult` to:
* 1. Decrypt the user key with the `currentMasterKey`
* 2. Re-encrypt that user key with the `newMasterKey`, resulting in a `newMasterKeyEncryptedUserKey`
* 3. Build a `PasswordRequest` object that gets POSTed to `"/accounts/password"`
*
* @param passwordInputResult credentials object received from the `InputPasswordComponent`
* @param userId the `userId`
* @throws if the `userId`, `currentMasterKey`, or `currentServerMasterKeyHash` is not found
*/
abstract changePassword(passwordInputResult: PasswordInputResult, userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,177 @@
import { mock, MockProxy } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PasswordInputResult } from "../input-password/password-input-result";
import { ChangePasswordService } from "./change-password.service.abstraction";
import { DefaultChangePasswordService } from "./default-change-password.service";
describe("DefaultChangePasswordService", () => {
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let sut: ChangePasswordService;
const userId = "userId" as UserId;
const user: Account = {
id: userId,
email: "email",
emailVerified: false,
name: "name",
};
const passwordInputResult: PasswordInputResult = {
currentMasterKey: new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
currentServerMasterKeyHash: "currentServerMasterKeyHash",
newPassword: "newPassword",
newPasswordHint: "newPasswordHint",
newMasterKey: new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
kdfConfig: new PBKDF2KdfConfig(),
};
const decryptedUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const newMasterKeyEncryptedUserKey: [UserKey, EncString] = [
decryptedUserKey,
{ encryptedString: "newMasterKeyEncryptedUserKey" } as EncString,
];
beforeEach(() => {
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
sut = new DefaultChangePasswordService(
keyService,
masterPasswordApiService,
masterPasswordService,
);
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(decryptedUserKey);
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(newMasterKeyEncryptedUserKey);
});
describe("changePassword()", () => {
it("should call the postPassword() API method with a the correct PasswordRequest credentials", async () => {
// Act
await sut.changePassword(passwordInputResult, userId);
// Assert
expect(masterPasswordApiService.postPassword).toHaveBeenCalledWith(
expect.objectContaining({
masterPasswordHash: passwordInputResult.currentServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
newMasterPasswordHash: passwordInputResult.newServerMasterKeyHash,
key: newMasterKeyEncryptedUserKey[1].encryptedString,
}),
);
});
it("should call decryptUserKeyWithMasterKey and encryptUserKeyWithMasterKey", async () => {
// Act
await sut.changePassword(passwordInputResult, userId);
// Assert
expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
passwordInputResult.currentMasterKey,
userId,
);
expect(keyService.encryptUserKeyWithMasterKey).toHaveBeenCalledWith(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
});
it("should throw if a userId was not found", async () => {
// Arrange
const userId: null = null;
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("userId not found");
});
it("should throw if a currentMasterKey was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentMasterKey = null;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
);
});
it("should throw if a currentServerMasterKeyHash was not found", async () => {
// Arrange
const incorrectPasswordInputResult = { ...passwordInputResult };
incorrectPasswordInputResult.currentServerMasterKeyHash = null;
// Act
const testFn = sut.changePassword(incorrectPasswordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow(
"currentMasterKey or currentServerMasterKeyHash not found",
);
});
it("should throw an error if user key decryption fails", async () => {
// Arrange
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(null);
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not decrypt user key");
});
it("should throw an error if postPassword() fails", async () => {
// Arrange
masterPasswordApiService.postPassword.mockRejectedValueOnce(new Error("error"));
// Act
const testFn = sut.changePassword(passwordInputResult, userId);
// Assert
await expect(testFn).rejects.toThrow("Could not change password");
expect(masterPasswordApiService.postPassword).toHaveBeenCalled();
});
});
describe("rotateUserKeyMasterPasswordAndEncryptedData()", () => {
it("should throw an error (the method is only implemented in Web)", async () => {
// Act
const testFn = sut.rotateUserKeyMasterPasswordAndEncryptedData(
"currentPassword",
"newPassword",
user,
"newPasswordHint",
);
// Assert
await expect(testFn).rejects.toThrow(
"rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web",
);
});
});
});

View File

@@ -0,0 +1,59 @@
import { PasswordInputResult, ChangePasswordService } from "@bitwarden/auth/angular";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";
export class DefaultChangePasswordService implements ChangePasswordService {
constructor(
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
) {}
async rotateUserKeyMasterPasswordAndEncryptedData(
currentPassword: string,
newPassword: string,
user: Account,
hint: string,
): Promise<void> {
throw new Error("rotateUserKeyMasterPasswordAndEncryptedData() is only implemented in Web");
}
async changePassword(passwordInputResult: PasswordInputResult, userId: UserId) {
if (!userId) {
throw new Error("userId not found");
}
if (!passwordInputResult.currentMasterKey || !passwordInputResult.currentServerMasterKeyHash) {
throw new Error("currentMasterKey or currentServerMasterKeyHash not found");
}
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
passwordInputResult.currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
throw new Error("Could not decrypt user key");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
passwordInputResult.newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = passwordInputResult.currentServerMasterKeyHash;
request.newMasterPasswordHash = passwordInputResult.newServerMasterKeyHash;
request.masterPasswordHint = passwordInputResult.newPasswordHint;
request.key = newMasterKeyEncryptedUserKey[1].encryptedString as string;
try {
await this.masterPasswordApiService.postPassword(request);
} catch {
throw new Error("Could not change password");
}
}
}

View File

@@ -8,6 +8,11 @@ export * from "./anon-layout/anon-layout-wrapper.component";
export * from "./anon-layout/anon-layout-wrapper-data.service";
export * from "./anon-layout/default-anon-layout-wrapper-data.service";
// change password
export * from "./change-password/change-password.component";
export * from "./change-password/change-password.service.abstraction";
export * from "./change-password/default-change-password.service";
// fingerprint dialog
export * from "./fingerprint-dialog/fingerprint-dialog.component";

View File

@@ -6,8 +6,8 @@
<bit-form-field
*ngIf="
inputPasswordFlow === InputPasswordFlow.ChangePassword ||
inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
flow === InputPasswordFlow.ChangePassword ||
flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
"
>
<bit-label>{{ "currentMasterPass" | i18n }}</bit-label>
@@ -58,12 +58,12 @@
</div>
<bit-form-field>
<bit-label>{{ "confirmMasterPassword" | i18n }}</bit-label>
<bit-label>{{ "confirmNewMasterPass" | i18n }}</bit-label>
<input
id="input-password-form_confirm-new-password"
id="input-password-form_new-password-confirm"
bitInput
type="password"
formControlName="confirmNewPassword"
formControlName="newPasswordConfirm"
/>
<button
type="button"
@@ -76,21 +76,33 @@
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | i18n }}</bit-label>
<input bitInput formControlName="hint" />
<input id="input-password-form_new-password-hint" bitInput formControlName="newPasswordHint" />
<bit-hint>
{{ "masterPassHintText" | i18n: formGroup.value.hint.length : maxHintLength.toString() }}
{{
"masterPassHintText"
| i18n: formGroup.value.newPasswordHint.length : maxHintLength.toString()
}}
</bit-hint>
</bit-form-field>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="checkForBreaches" />
<input
id="input-password-form_check-for-breaches"
type="checkbox"
bitCheckbox
formControlName="checkForBreaches"
/>
<bit-label>{{ "checkForBreaches" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
*ngIf="inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
>
<input type="checkbox" bitCheckbox formControlName="rotateUserKey" />
<bit-form-control *ngIf="flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation">
<input
id="input-password-form_rotate-user-key"
type="checkbox"
bitCheckbox
formControlName="rotateUserKey"
(change)="rotateUserKeyClicked()"
/>
<bit-label>
{{ "rotateAccountEncKey" | i18n }}
<a

View File

@@ -1,5 +1,6 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators, FormGroup } from "@angular/forms";
import { ReactiveFormsModule, FormBuilder, Validators, FormControl } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -9,9 +10,13 @@ import {
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
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 { 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 { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
AsyncActionsModule,
ButtonModule,
@@ -23,7 +28,12 @@ import {
ToastService,
Translation,
} from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import {
DEFAULT_KDF_CONFIG,
KdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
@@ -34,30 +44,41 @@ import { compareInputs, ValidationGoal } from "../validators/compare-inputs.vali
import { PasswordInputResult } from "./password-input-result";
/**
* Determines which form input elements will be displayed in the UI.
* Determines which form elements will be displayed in the UI
* and which cryptographic keys will be created and emitted.
*/
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum InputPasswordFlow {
/**
* - Input: New password
* - Input: Confirm new password
* - Input: Hint
* - Checkbox: Check for breaches
* Form elements displayed:
* - [Input] New password
* - [Input] New password confirm
* - [Input] New password hint
* - [Checkbox] Check for breaches
*/
SetInitialPassword,
/**
* Everything above, plus:
* - Input: Current password (as the first element in the UI)
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)
*/
ChangePassword,
/**
* Everything above, plus:
* - Checkbox: Rotate account encryption key (as the last element in the UI)
* All form elements above, plus: [Checkbox] Rotate account encryption key (as the last element in the UI)
*/
ChangePasswordWithOptionalUserKeyRotation,
}
interface InputPasswordForm {
newPassword: FormControl<string>;
newPasswordConfirm: FormControl<string>;
newPasswordHint: FormControl<string>;
checkForBreaches: FormControl<boolean>;
currentPassword?: FormControl<string>;
rotateUserKey?: FormControl<boolean>;
}
@Component({
standalone: true,
selector: "auth-input-password",
@@ -80,9 +101,10 @@ export class InputPasswordComponent implements OnInit {
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Output() onSecondaryButtonClick = new EventEmitter<void>();
@Input({ required: true }) inputPasswordFlow!: InputPasswordFlow;
@Input({ required: true }) email!: string;
@Input({ required: true }) flow!: InputPasswordFlow;
@Input({ required: true, transform: (val: string) => val.trim().toLowerCase() }) email!: string;
@Input() userId?: UserId;
@Input() loading = false;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@@ -93,6 +115,7 @@ export class InputPasswordComponent implements OnInit {
protected secondaryButtonTextStr: string = "";
protected InputPasswordFlow = InputPasswordFlow;
private kdfConfig: KdfConfig | null = null;
private minHintLength = 0;
protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength;
@@ -101,64 +124,93 @@ export class InputPasswordComponent implements OnInit {
protected showErrorSummary = false;
protected showPassword = false;
protected formGroup = this.formBuilder.nonNullable.group(
protected formGroup = this.formBuilder.nonNullable.group<InputPasswordForm>(
{
newPassword: ["", [Validators.required, Validators.minLength(this.minPasswordLength)]],
confirmNewPassword: ["", Validators.required],
hint: [
"", // must be string (not null) because we check length in validation
[Validators.minLength(this.minHintLength), Validators.maxLength(this.maxHintLength)],
],
checkForBreaches: [true],
newPassword: this.formBuilder.nonNullable.control("", [
Validators.required,
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: [
compareInputs(
ValidationGoal.InputsShouldMatch,
"newPassword",
"confirmNewPassword",
"newPasswordConfirm",
this.i18nService.t("masterPassDoesntMatch"),
),
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"newPassword",
"hint",
"newPasswordHint",
this.i18nService.t("hintEqualsPassword"),
),
],
},
);
protected get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions.minLength > 0
) {
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
constructor(
private auditService: AuditService,
private keyService: KeyService,
private cipherService: CipherService,
private dialogService: DialogService,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private kdfConfigService: KdfConfigService,
private keyService: KeyService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
private toastService: ToastService,
) {}
ngOnInit(): void {
this.addFormFieldsIfNecessary();
this.setButtonText();
}
private addFormFieldsIfNecessary() {
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
this.formGroup.addControl(
"currentPassword",
this.formBuilder.control("", Validators.required),
this.formBuilder.nonNullable.control("", Validators.required),
);
this.formGroup.addValidators([
compareInputs(
ValidationGoal.InputsShouldNotMatch,
"currentPassword",
"newPassword",
this.i18nService.t("yourNewPasswordCannotBeTheSameAsYourCurrentPassword"),
),
]);
}
if (this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
// https://github.com/angular/angular/issues/48794
(this.formGroup as FormGroup<any>).addControl(
"rotateUserKey",
this.formBuilder.control(false),
);
if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
this.formGroup.addControl("rotateUserKey", this.formBuilder.nonNullable.control(false));
}
}
private setButtonText() {
if (this.primaryButtonText) {
this.primaryButtonTextStr = this.i18nService.t(
this.primaryButtonText.key,
@@ -174,22 +226,9 @@ export class InputPasswordComponent implements OnInit {
}
}
get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions.minLength > 0
) {
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
protected submit = async () => {
this.verifyFlowAndUserId();
this.formGroup.markAllAsTouched();
if (this.formGroup.invalid) {
@@ -197,79 +236,204 @@ export class InputPasswordComponent implements OnInit {
return;
}
const newPassword = this.formGroup.controls.newPassword.value;
const passwordEvaluatedSuccessfully = await this.evaluateNewPassword(
newPassword,
this.passwordStrengthScore,
this.formGroup.controls.checkForBreaches.value,
);
if (!passwordEvaluatedSuccessfully) {
return;
}
// Create and hash new master key
const kdfConfig = DEFAULT_KDF_CONFIG;
if (this.email == null) {
if (!this.email) {
throw new Error("Email is required to create master key.");
}
const masterKey = await this.keyService.makeMasterKey(
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) {
return;
}
}
// 3. Verify new password
const newPasswordVerified = await this.verifyNewPassword(
newPassword,
this.email.trim().toLowerCase(),
kdfConfig,
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 serverMasterKeyHash = await this.keyService.hashMasterKey(
const newServerMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
masterKey,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const localMasterKeyHash = await this.keyService.hashMasterKey(
const newLocalMasterKeyHash = await this.keyService.hashMasterKey(
newPassword,
masterKey,
newMasterKey,
HashPurpose.LocalAuthorization,
);
const passwordInputResult: PasswordInputResult = {
newPassword,
hint: this.formGroup.controls.hint.value,
kdfConfig,
masterKey,
serverMasterKeyHash,
localMasterKeyHash,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig: this.kdfConfig,
};
if (
this.inputPasswordFlow === InputPasswordFlow.ChangePassword ||
this.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
this.flow === InputPasswordFlow.ChangePassword ||
this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation
) {
passwordInputResult.currentPassword = this.formGroup.get("currentPassword")?.value;
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.inputPasswordFlow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) {
passwordInputResult.rotateUserKey = this.formGroup.get("rotateUserKey")?.value;
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);
};
// Returns true if the password passes all checks, false otherwise
private async evaluateNewPassword(
/**
* 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");
}
}
}
/**
* Returns `true` if the current password is correct (it can be used to successfully decrypt
* the masterKeyEncrypedUserKey), `false` otherwise
*/
private async verifyCurrentPassword(
currentPassword: string,
kdfConfig: KdfConfig,
): Promise<boolean> {
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,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("invalidMasterPassword"),
});
return false;
}
return true;
}
/**
* Returns `true` if the new password is not weak or breached and it passes
* any enforced org policy options, `false` otherwise
*/
private async verifyNewPassword(
newPassword: string,
passwordStrengthScore: PasswordStrengthScore,
checkForBreaches: boolean,
) {
): Promise<boolean> {
// Check if the password is breached, weak, or both
const passwordIsBreached =
checkForBreaches && (await this.auditService.passwordLeaked(newPassword));
checkForBreaches && (await this.auditService.passwordLeaked(newPassword)) > 0;
const passwordWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
const passwordIsWeak = passwordStrengthScore != null && passwordStrengthScore < 3;
if (passwordIsBreached && passwordWeak) {
if (passwordIsBreached && passwordIsWeak) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "weakAndExposedMasterPassword" },
content: { key: "weakAndBreachedMasterPasswordDesc" },
@@ -279,7 +443,7 @@ export class InputPasswordComponent implements OnInit {
if (!userAcceptedDialog) {
return false;
}
} else if (passwordWeak) {
} else if (passwordIsWeak) {
const userAcceptedDialog = await this.dialogService.openSimpleDialog({
title: { key: "weakMasterPasswordDesc" },
content: { key: "weakMasterPasswordDesc" },
@@ -321,4 +485,67 @@ export class InputPasswordComponent implements OnInit {
return true;
}
protected async rotateUserKeyClicked() {
const rotateUserKeyCtrl = this.formGroup.controls.rotateUserKey;
const rotateUserKey = rotateUserKeyCtrl?.value;
if (rotateUserKey) {
if (!this.userId) {
throw new Error("userId not passed down");
}
const ciphers = await this.cipherService.getAllDecrypted(this.userId);
let hasOldAttachments = false;
if (ciphers != null) {
for (let i = 0; i < ciphers.length; i++) {
if (ciphers[i].organizationId == null && ciphers[i].hasOldAttachments) {
hasOldAttachments = true;
break;
}
}
}
if (hasOldAttachments) {
const learnMore = await this.dialogService.openSimpleDialog({
title: { key: "warning" },
content: { key: "oldAttachmentsNeedFixDesc" },
acceptButtonText: { key: "learnMore" },
cancelButtonText: { key: "close" },
type: "warning",
});
if (learnMore) {
this.platformUtilsService.launchUri(
"https://bitwarden.com/help/attachments/#add-storage-space",
);
}
rotateUserKeyCtrl.setValue(false);
return;
}
const result = await this.dialogService.openSimpleDialog({
title: { key: "rotateEncKeyTitle" },
content:
this.i18nService.t("updateEncryptionKeyWarning") +
" " +
this.i18nService.t("updateEncryptionKeyAccountExportWarning") +
" " +
this.i18nService.t("rotateEncKeyConfirmation"),
type: "warning",
});
if (!result) {
rotateUserKeyCtrl.setValue(false);
}
}
}
protected getPasswordStrengthScore(score: PasswordStrengthScore) {
this.passwordStrengthScore = score;
}
}

View File

@@ -6,9 +6,10 @@ import * as stories from "./input-password.stories.ts";
# InputPassword Component
The `InputPasswordComponent` allows a user to enter master password related credentials. On
submission it creates a master key, master key hash, and emits those values to the parent (along
with the other values found in `PasswordInputResult`).
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 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
@@ -16,12 +17,27 @@ the parent component to act on those values as needed.
<br />
## Table of Contents
- [@Inputs](#inputs)
- [@Outputs](#outputs)
- [The InputPasswordFlow](#the-inputpasswordflow)
- [HTML - Form Fields](#html---form-fields)
- [TypeScript - Credential Generation](#typescript---credential-generation)
- [Difference between AccountRegistration and SetInitialPasswordAuthedUser](#difference-between-accountregistration-and-setinitialpasswordautheduser)
- [Validation](#validation)
- [Submit Logic](#submit-logic)
- [Example](#example)
<br />
## `@Input()`'s
**Required**
- `inputPasswordFlow` - the parent component must provide the correct flow, which is used to
determine which form input elements will be displayed in the UI.
- `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.
@@ -29,13 +45,15 @@ the parent component to act on those values as needed.
- `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` true.
The primary button will also show a spinner if `loading` is true.
- `masterPasswordPolicyOptions` - used to display and enforce master password policy requirements.
- `inlineButtons` - takes a boolean that determines if the button(s) should be displayed inline (as
opposed to full-width)
- `primaryButtonText` - takes a `Translation` object that can be used as button text
- `secondaryButtonText` - takes a `Translation` object that can be used as button text
<br />
## `@Output()`'s
- `onPasswordFormSubmit` - on form submit, emits a `PasswordInputResult` object
@@ -45,25 +63,31 @@ the parent component to act on those values as needed.
<br />
## Form Input Fields
## The `InputPasswordFlow`
The `InputPasswordComponent` can handle up to 6 different form input fields, depending on the
`InputPasswordFlow` provided by the parent component.
The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the
credential generation logic of the component.
**InputPasswordFlow.SetInitialPassword**
<br />
### 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**
**`InputPasswordFlow.ChangePassword`**
Includes everything above, plus:
- Input: Current password (as the first element in the UI)
**InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation**
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**
Includes everything above, plus:
@@ -71,49 +95,122 @@ Includes everything above, plus:
<br />
### 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`).
<br />
### Difference between `AccountRegistration` 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
<br />
- `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&mdash;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:
- If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG`
- If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the
`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:
- If the passed down flow is `AccountRegistration`, 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`
If either of these checks is not met, the component throws to alert the dev of a mistake.
<br />
## Validation
Validation ensures that:
Form validators ensure that:
- The current password and new password are NOT the same
- 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)
<br />
## On Submit
## Submit Logic
When the form is submitted, the `InputPasswordComponent` does the following in order:
1. If the user selected the checkbox to check for password breaches, they will recieve a popup
dialog if their entered password is found in a breach. The popup will give them the option to
continue with the password or to back out and choose a different password.
2. If there is a master password policy being enforced by an org, it will check to make sure the
entered master password meets the policy requirements.
3. The component will use the password, email, and default kdfConfig to create a master key and
master key hash.
4. The component will emit the following values (defined in the `PasswordInputResult` interface) to
be used by the parent component as needed:
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
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
`PasswordInputResult`) to be used by the parent as needed.
```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;
hint: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string; // included if the flow is ChangePassword or ChangePasswordWithOptionalUserKeyRotation
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
kdfConfig: KdfConfig;
rotateUserKey?: boolean; // included if the flow is ChangePasswordWithOptionalUserKeyRotation
}
```
# Example - InputPasswordFlow.SetInitialPassword
# Example
<Story of={stories.SetInitialPassword} />
**`InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation`**
<br />
# Example - With Policy Requrements
<Story of={stories.WithPolicies} />
<Story of={stories.ChangePasswordWithOptionalUserKeyRotation} />

View File

@@ -2,14 +2,20 @@ import { importProvidersFrom } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { action } from "@storybook/addon-actions";
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
import { of } from "rxjs";
import { ZXCVBNResult } from "zxcvbn";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
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 { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
// FIXME: remove `/apps` import from `/libs`
// FIXME: remove `src` and fix import
@@ -26,12 +32,47 @@ export default {
providers: [
importProvidersFrom(PreloadedEnglishI18nModule),
importProvidersFrom(BrowserAnimationsModule),
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "1" as UserId,
name: "User",
email: "user@email.com",
emailVerified: true,
}),
},
},
{
provide: AuditService,
useValue: {
passwordLeaked: () => Promise.resolve(1),
} as Partial<AuditService>,
},
{
provide: CipherService,
useValue: {
getAllDecrypted: () => Promise.resolve([]),
},
},
{
provide: KdfConfigService,
useValue: {
getKdfConfig$: () => of(DEFAULT_KDF_CONFIG),
},
},
{
provide: MasterPasswordServiceAbstraction,
useValue: {
decryptUserKeyWithMasterKey: () => Promise.resolve("example-decrypted-user-key"),
},
},
{
provide: PlatformUtilsService,
useValue: {
launchUri: () => Promise.resolve(true),
},
},
{
provide: KeyService,
useValue: {
@@ -87,11 +128,14 @@ export default {
],
args: {
InputPasswordFlow: {
SetInitialPassword: InputPasswordFlow.SetInitialPassword,
AccountRegistration: InputPasswordFlow.AccountRegistration,
SetInitialPasswordAuthedUser: InputPasswordFlow.SetInitialPasswordAuthedUser,
ChangePassword: InputPasswordFlow.ChangePassword,
ChangePasswordWithOptionalUserKeyRotation:
InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation,
},
userId: "1" as UserId,
email: "user@email.com",
masterPasswordPolicyOptions: {
minComplexity: 4,
minLength: 14,
@@ -108,11 +152,27 @@ export default {
type Story = StoryObj<InputPasswordComponent>;
export const SetInitialPassword: Story = {
export const AccountRegistration: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"></auth-input-password>
<auth-input-password
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
></auth-input-password>
`,
}),
};
export const SetInitialPasswordAuthedUser: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password
[flow]="InputPasswordFlow.SetInitialPasswordAuthedUser"
[email]="email"
[userId]="userId"
></auth-input-password>
`,
}),
};
@@ -121,7 +181,11 @@ export const ChangePassword: Story = {
render: (args) => ({
props: args,
template: `
<auth-input-password [inputPasswordFlow]="InputPasswordFlow.ChangePassword"></auth-input-password>
<auth-input-password
[flow]="InputPasswordFlow.ChangePassword"
[email]="email"
[userId]="userId"
></auth-input-password>
`,
}),
};
@@ -131,7 +195,9 @@ export const ChangePasswordWithOptionalUserKeyRotation: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
[flow]="InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation"
[email]="email"
[userId]="userId"
></auth-input-password>
`,
}),
@@ -142,7 +208,9 @@ export const WithPolicies: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.SetInitialPasswordAuthedUser"
[email]="email"
[userId]="userId"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
></auth-input-password>
`,
@@ -154,7 +222,8 @@ export const SecondaryButton: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
@@ -167,7 +236,8 @@ export const SecondaryButtonWithPlaceHolderText: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'backTo', placeholders: ['homepage'] }"
(onSecondaryButtonClick)="onSecondaryButtonClick()"
></auth-input-password>
@@ -180,7 +250,8 @@ export const InlineButton: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[inlineButtons]="true"
></auth-input-password>
`,
@@ -192,7 +263,8 @@ export const InlineButtons: Story = {
props: args,
template: `
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="InputPasswordFlow.AccountRegistration"
[email]="email"
[secondaryButtonText]="{ key: 'cancel' }"
[inlineButtons]="true"
(onSecondaryButtonClick)="onSecondaryButtonClick()"

View File

@@ -1,13 +1,18 @@
import { MasterKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KdfConfig } from "@bitwarden/key-management";
export interface PasswordInputResult {
newPassword: string;
hint: string;
kdfConfig: PBKDF2KdfConfig;
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
currentPassword?: string;
currentMasterKey?: MasterKey;
currentServerMasterKeyHash?: string;
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
kdfConfig: KdfConfig;
rotateUserKey?: boolean;
}

View File

@@ -58,12 +58,12 @@ describe("DefaultRegistrationFinishService", () => {
emailVerificationToken = "emailVerificationToken";
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
passwordInputResult = {
masterKey: masterKey,
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
newMasterKey: masterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
newPassword: "password",
newPasswordHint: "newPasswordHint",
newPassword: "newPassword",
};
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
@@ -93,8 +93,8 @@ describe("DefaultRegistrationFinishService", () => {
expect.objectContaining({
email,
emailVerificationToken: emailVerificationToken,
masterPasswordHash: passwordInputResult.serverMasterKeyHash,
masterPasswordHint: passwordInputResult.hint,
masterPasswordHash: passwordInputResult.newServerMasterKeyHash,
masterPasswordHint: passwordInputResult.newPasswordHint,
userSymmetricKey: userKeyEncString.encryptedString,
userAsymmetricKeys: {
publicKey: userKeyPair[0],

View File

@@ -36,7 +36,7 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
providerUserId?: string,
): Promise<void> {
const [newUserKey, newEncUserKey] = await this.keyService.makeUserKey(
passwordInputResult.masterKey,
passwordInputResult.newMasterKey,
);
if (!newUserKey || !newEncUserKey) {
@@ -79,8 +79,8 @@ export class DefaultRegistrationFinishService implements RegistrationFinishServi
const registerFinishRequest = new RegisterFinishRequest(
email,
passwordInputResult.serverMasterKeyHash,
passwordInputResult.hint,
passwordInputResult.newServerMasterKeyHash,
passwordInputResult.newPasswordHint,
encryptedUserKey,
userAsymmetricKeysRequest,
passwordInputResult.kdfConfig.kdfType,

View File

@@ -5,7 +5,7 @@
<auth-input-password
*ngIf="!loading"
[email]="email"
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[flow]="inputPasswordFlow"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[loading]="submitting"
[primaryButtonText]="{ key: 'createAccount' }"

View File

@@ -39,8 +39,7 @@ import { RegistrationFinishService } from "./registration-finish.service";
export class RegistrationFinishComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
InputPasswordFlow = InputPasswordFlow;
inputPasswordFlow = InputPasswordFlow.AccountRegistration;
loading = true;
submitting = false;
email: string;

View File

@@ -111,12 +111,12 @@ describe("DefaultSetPasswordJitService", () => {
userId = "userId" as UserId;
passwordInputResult = {
masterKey: masterKey,
serverMasterKeyHash: "serverMasterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
hint: "hint",
newMasterKey: masterKey,
newServerMasterKeyHash: "newServerMasterKeyHash",
newLocalMasterKeyHash: "newLocalMasterKeyHash",
newPasswordHint: "newPasswordHint",
kdfConfig: DEFAULT_KDF_CONFIG,
newPassword: "password",
newPassword: "newPassword",
};
credentials = {
@@ -131,9 +131,9 @@ describe("DefaultSetPasswordJitService", () => {
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
passwordInputResult.serverMasterKeyHash,
passwordInputResult.newServerMasterKeyHash,
protectedUserKey[1].encryptedString,
passwordInputResult.hint,
passwordInputResult.newPasswordHint,
orgSsoIdentifier,
keysRequest,
passwordInputResult.kdfConfig.kdfType,

View File

@@ -20,7 +20,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
SetPasswordCredentials,
@@ -43,10 +43,10 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const {
masterKey,
serverMasterKeyHash,
localMasterKeyHash,
hint,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,
newPasswordHint,
kdfConfig,
orgSsoIdentifier,
orgId,
@@ -60,7 +60,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
}
}
const protectedUserKey = await this.makeProtectedUserKey(masterKey, userId);
const protectedUserKey = await this.makeProtectedUserKey(newMasterKey, userId);
if (protectedUserKey == null) {
throw new Error("protectedUserKey not found. Could not set password.");
}
@@ -70,12 +70,12 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest(
serverMasterKeyHash,
newServerMasterKeyHash,
protectedUserKey[1].encryptedString,
hint,
newPasswordHint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType, // kdfConfig is always DEFAULT_KDF_CONFIG (see InputPasswordComponent)
kdfConfig.kdfType,
kdfConfig.iterations,
);
@@ -85,14 +85,14 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update account decryption options in state
await this.updateAccountDecryptionProperties(masterKey, kdfConfig, protectedUserKey, userId);
await this.updateAccountDecryptionProperties(newMasterKey, kdfConfig, protectedUserKey, userId);
await this.keyService.setPrivateKey(keyPair[1].encryptedString, userId);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(serverMasterKeyHash, orgId, userId);
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
}
}
@@ -127,7 +127,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
private async updateAccountDecryptionProperties(
masterKey: MasterKey,
kdfConfig: PBKDF2KdfConfig,
kdfConfig: KdfConfig,
protectedUserKey: [UserKey, EncString],
userId: UserId,
) {

View File

@@ -13,11 +13,12 @@
</app-callout>
<auth-input-password
[inputPasswordFlow]="InputPasswordFlow.SetInitialPassword"
[primaryButtonText]="{ key: 'createAccount' }"
[flow]="inputPasswordFlow"
[email]="email"
[userId]="userId"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
[primaryButtonText]="{ key: 'createAccount' }"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</ng-container>

View File

@@ -3,7 +3,7 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@@ -36,7 +36,7 @@ import {
imports: [CommonModule, InputPasswordComponent, JslibModule],
})
export class SetPasswordJitComponent implements OnInit {
protected InputPasswordFlow = InputPasswordFlow;
protected inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAuthedUser;
protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string;
@@ -60,9 +60,9 @@ export class SetPasswordJitComponent implements OnInit {
) {}
async ngOnInit() {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.userId = activeAccount?.id;
this.email = activeAccount?.email;
await this.syncService.fullSync(true);
this.syncLoading = false;
@@ -97,14 +97,12 @@ export class SetPasswordJitComponent implements OnInit {
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const credentials: SetPasswordCredentials = {
...passwordInputResult,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
userId,
userId: this.userId,
};
try {

View File

@@ -2,14 +2,14 @@
// @ts-strict-ignore
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KdfConfig } from "@bitwarden/key-management";
export interface SetPasswordCredentials {
masterKey: MasterKey;
serverMasterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
newLocalMasterKeyHash: string;
newPasswordHint: string;
kdfConfig: KdfConfig;
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;

View File

@@ -10,7 +10,7 @@ import {
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import { lastValueFrom, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -84,10 +84,8 @@ import {
ReactiveFormsModule,
FormFieldModule,
AsyncActionsModule,
RouterLink,
CheckboxModule,
ButtonModule,
TwoFactorOptionsComponent, // used as dialog
TwoFactorAuthAuthenticatorComponent,
TwoFactorAuthEmailComponent,
TwoFactorAuthDuoComponent,

View File

@@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => {
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
useOrganizationDomains: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
},

View File

@@ -21,6 +21,7 @@ export class OrganizationData {
use2fa: boolean;
useApi: boolean;
useSso: boolean;
useOrganizationDomains: boolean;
useKeyConnector: boolean;
useScim: boolean;
useCustomPermissions: boolean;
@@ -87,6 +88,7 @@ export class OrganizationData {
this.use2fa = response.use2fa;
this.useApi = response.useApi;
this.useSso = response.useSso;
this.useOrganizationDomains = response.useOrganizationDomains;
this.useKeyConnector = response.useKeyConnector;
this.useScim = response.useScim;
this.useCustomPermissions = response.useCustomPermissions;

View File

@@ -28,6 +28,7 @@ export class Organization {
use2fa: boolean;
useApi: boolean;
useSso: boolean;
useOrganizationDomains: boolean;
useKeyConnector: boolean;
useScim: boolean;
useCustomPermissions: boolean;
@@ -111,6 +112,7 @@ export class Organization {
this.use2fa = obj.use2fa;
this.useApi = obj.useApi;
this.useSso = obj.useSso;
this.useOrganizationDomains = obj.useOrganizationDomains;
this.useKeyConnector = obj.useKeyConnector;
this.useScim = obj.useScim;
this.useCustomPermissions = obj.useCustomPermissions;
@@ -281,7 +283,7 @@ export class Organization {
}
get canManageDomainVerification() {
return (this.isAdmin || this.permissions.manageSso) && this.useSso;
return (this.isAdmin || this.permissions.manageSso) && this.useOrganizationDomains;
}
get canManageScim() {

View File

@@ -14,6 +14,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
use2fa: boolean;
useApi: boolean;
useSso: boolean;
useOrganizationDomains: boolean;
useKeyConnector: boolean;
useScim: boolean;
useCustomPermissions: boolean;
@@ -70,6 +71,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.use2fa = this.getResponseProperty("Use2fa");
this.useApi = this.getResponseProperty("UseApi");
this.useSso = this.getResponseProperty("UseSso");
this.useOrganizationDomains = this.getResponseProperty("UseOrganizationDomains");
this.useKeyConnector = this.getResponseProperty("UseKeyConnector") ?? false;
this.useScim = this.getResponseProperty("UseScim") ?? false;
this.useCustomPermissions = this.getResponseProperty("UseCustomPermissions") ?? false;

View File

@@ -11,8 +11,10 @@ export abstract class OrganizationSponsorshipApiServiceAbstraction {
friendlyName?: string,
): Promise<void>;
abstract deleteRevokeSponsorship: (
abstract deleteRevokeSponsorship: (sponsoringOrganizationId: string) => Promise<void>;
abstract deleteAdminInitiatedRevokeSponsorship: (
sponsoringOrganizationId: string,
isAdminInitiated?: boolean,
sponsoredFriendlyName: string,
) => Promise<void>;
}

View File

@@ -16,7 +16,10 @@ export class OrganizationSponsorshipApiService
): Promise<ListResponse<OrganizationSponsorshipInvitesResponse>> {
const r = await this.apiService.send(
"GET",
"/organization/sponsorship/" + sponsoredOrgId + "/sponsored",
"/organization/sponsorship/" +
(this.platformUtilsService.isSelfHost() ? "self-hosted/" : "") +
sponsoredOrgId +
"/sponsored",
null,
true,
true,
@@ -44,11 +47,30 @@ export class OrganizationSponsorshipApiService
): Promise<void> {
const basePath = "/organization/sponsorship/";
const hostPath = this.platformUtilsService.isSelfHost() ? "self-hosted/" : "";
const queryParam = `?isAdminInitiated=${isAdminInitiated}`;
return await this.apiService.send(
"DELETE",
basePath + hostPath + sponsoringOrganizationId + queryParam,
basePath + hostPath + sponsoringOrganizationId,
null,
true,
false,
);
}
async deleteAdminInitiatedRevokeSponsorship(
sponsoringOrganizationId: string,
sponsoredFriendlyName: string,
): Promise<void> {
const basePath = "/organization/sponsorship/";
const hostPath = this.platformUtilsService.isSelfHost() ? "self-hosted/" : "";
return await this.apiService.send(
"DELETE",
basePath +
hostPath +
sponsoringOrganizationId +
"/" +
encodeURIComponent(sponsoredFriendlyName) +
"/revoke",
null,
true,
false,

View File

@@ -18,6 +18,7 @@ export enum FeatureFlag {
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
/* Auth */
PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor",
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
/* Autofill */
@@ -115,6 +116,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
/* Auth */
[FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE,
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
/* Billing */

View File

@@ -315,6 +315,7 @@ describe("KeyConnectorService", () => {
name: "TEST_KEY_CONNECTOR_ORG",
usePolicies: true,
useSso: true,
useOrganizationDomains: true,
useKeyConnector: usesKeyConnector,
useScim: true,
useGroups: true,

View File

@@ -164,8 +164,8 @@ export class Fido2Credential extends Domain {
keyCurve: this.keyCurve.toJSON(),
keyValue: this.keyValue.toJSON(),
rpId: this.rpId.toJSON(),
userHandle: this.userHandle.toJSON(),
userName: this.userName.toJSON(),
userHandle: this.userHandle?.toJSON(),
userName: this.userName?.toJSON(),
counter: this.counter.toJSON(),
rpName: this.rpName?.toJSON(),
userDisplayName: this.userDisplayName?.toJSON(),

View File

@@ -1,17 +1,12 @@
import { DIALOG_DATA, DialogModule, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { getAllByRole, userEvent } from "@storybook/test";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { SharedModule } from "../shared";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { DialogComponent } from "./dialog/dialog.component";
@@ -24,12 +19,7 @@ interface Animal {
}
@Component({
template: `
<bit-layout>
<button class="tw-mr-2" bitButton type="button" (click)="openDialog()">Open Dialog</button>
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
</bit-layout>
`,
template: `<button bitButton type="button" (click)="openDialog()">Open Dialog</button>`,
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
@@ -41,14 +31,6 @@ class StoryDialogComponent {
},
});
}
openDrawer() {
this.dialogService.openDrawer(StoryDialogContentComponent, {
data: {
animal: "panda",
},
});
}
}
@Component({
@@ -83,37 +65,25 @@ export default {
title: "Component Library/Dialogs/Service",
component: StoryDialogComponent,
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
declarations: [StoryDialogContentComponent],
imports: [
SharedModule,
ButtonModule,
NoopAnimationsModule,
DialogModule,
IconButtonModule,
DialogCloseDirective,
DialogComponent,
DialogTitleContainerDirective,
RouterTestingModule,
LayoutComponent,
],
providers: [DialogService],
}),
applicationConfig({
providers: [
DialogService,
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
close: "Close",
search: "Search",
skipToContent: "Skip to content",
submenu: "submenu",
toggleCollapse: "toggle collapse",
toggleSideNavigation: "Toggle side navigation",
yes: "Yes",
no: "No",
loading: "Loading",
});
},
},
@@ -130,21 +100,4 @@ export default {
type Story = StoryObj<StoryDialogComponent>;
export const Default: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[0];
await userEvent.click(button);
},
};
/** Drawers must be a descendant of `bit-layout`. */
export const Drawer: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[1];
await userEvent.click(button);
},
};
export const Default: Story = {};

View File

@@ -1,25 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
Dialog as CdkDialog,
DialogConfig as CdkDialogConfig,
DialogRef as CdkDialogRefBase,
DIALOG_DATA,
DialogCloseOptions,
DEFAULT_DIALOG_CONFIG,
Dialog,
DialogConfig,
DialogRef,
DIALOG_SCROLL_STRATEGY,
} from "@angular/cdk/dialog";
import { ComponentType, ScrollStrategy } from "@angular/cdk/overlay";
import { ComponentPortal, Portal } from "@angular/cdk/portal";
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ComponentType, Overlay, OverlayContainer, ScrollStrategy } from "@angular/cdk/overlay";
import {
Inject,
Injectable,
Injector,
OnDestroy,
Optional,
SkipSelf,
TemplateRef,
} from "@angular/core";
import { NavigationEnd, Router } from "@angular/router";
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DrawerService } from "../drawer/drawer.service";
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
import { SimpleDialogOptions } from "./simple-dialog/types";
import { SimpleDialogOptions, Translation } from "./simple-dialog/types";
/**
* The default `BlockScrollStrategy` does not work well with virtual scrolling.
@@ -42,163 +48,61 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
detach() {}
}
export abstract class DialogRef<R = unknown, C = unknown>
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
{
abstract readonly isDrawer?: boolean;
// --- From CdkDialogRef ---
abstract close(result?: R, options?: DialogCloseOptions): void;
abstract readonly closed: Observable<R | undefined>;
abstract disableClose: boolean | undefined;
/**
* @deprecated
* Does not work with drawer dialogs.
**/
abstract componentInstance: C | null;
}
export type DialogConfig<D = unknown, R = unknown> = Pick<
CdkDialogConfig<D, R>,
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
>;
class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
readonly isDrawer = true;
private _closed = new Subject<R | undefined>();
closed = this._closed.asObservable();
disableClose = false;
/** The portal containing the drawer */
portal?: Portal<unknown>;
constructor(private drawerService: DrawerService) {}
close(result?: R, _options?: DialogCloseOptions): void {
if (this.disableClose) {
return;
}
this.drawerService.close(this.portal!);
this._closed.next(result);
this._closed.complete();
}
componentInstance: C | null = null;
}
/**
* DialogRef that delegates functionality to the CDK implementation
**/
export class CdkDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
readonly isDrawer = false;
/** This is not available until after construction, @see DialogService.open. */
cdkDialogRefBase!: CdkDialogRefBase<R, C>;
// --- Delegated to CdkDialogRefBase ---
close(result?: R, options?: DialogCloseOptions): void {
this.cdkDialogRefBase.close(result, options);
}
get closed(): Observable<R | undefined> {
return this.cdkDialogRefBase.closed;
}
get disableClose(): boolean | undefined {
return this.cdkDialogRefBase.disableClose;
}
set disableClose(value: boolean | undefined) {
this.cdkDialogRefBase.disableClose = value;
}
// Delegate the `componentInstance` property to the CDK DialogRef
get componentInstance(): C | null {
return this.cdkDialogRefBase.componentInstance;
}
}
@Injectable()
export class DialogService {
private dialog = inject(CdkDialog);
private drawerService = inject(DrawerService);
private injector = inject(Injector);
private router = inject(Router, { optional: true });
private authService = inject(AuthService, { optional: true });
private i18nService = inject(I18nService);
export class DialogService extends Dialog implements OnDestroy {
private _destroy$ = new Subject<void>();
private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"];
private defaultScrollStrategy = new CustomBlockScrollStrategy();
private activeDrawer: DrawerDialogRef<any, any> | null = null;
constructor() {
/**
* TODO: This logic should exist outside of `libs/components`.
* @see https://bitwarden.atlassian.net/browse/CL-657
**/
private defaultScrollStrategy = new CustomBlockScrollStrategy();
constructor(
/** Parent class constructor */
_overlay: Overlay,
_injector: Injector,
@Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig,
@Optional() @SkipSelf() _parentDialog: Dialog,
_overlayContainer: OverlayContainer,
@Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any,
/** Not in parent class */
@Optional() router: Router,
@Optional() authService: AuthService,
protected i18nService: I18nService,
) {
super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy);
/** Close all open dialogs if the vault locks */
if (this.router && this.authService) {
this.router.events
if (router && authService) {
router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
switchMap(() => this.authService!.getAuthStatus()),
switchMap(() => authService.getAuthStatus()),
filter((v) => v !== AuthenticationStatus.Unlocked),
takeUntilDestroyed(),
takeUntil(this._destroy$),
)
.subscribe(() => this.closeAll());
}
}
open<R = unknown, D = unknown, C = unknown>(
override ngOnDestroy(): void {
this._destroy$.next();
this._destroy$.complete();
super.ngOnDestroy();
}
override open<R = unknown, D = unknown, C = unknown>(
componentOrTemplateRef: ComponentType<C> | TemplateRef<C>,
config?: DialogConfig<D, DialogRef<R, C>>,
): DialogRef<R, C> {
/**
* This is a bit circular in nature:
* We need the DialogRef instance for the DI injector that is passed *to* `Dialog.open`,
* but we get the base CDK DialogRef instance *from* `Dialog.open`.
*
* To break the circle, we define CDKDialogRef as a wrapper for the CDKDialogRefBase.
* This allows us to create the class instance and provide the base instance later, almost like "deferred inheritance".
**/
const ref = new CdkDialogRef<R, C>();
const injector = this.createInjector({
data: config?.data,
dialogRef: ref,
});
// Merge the custom config with the default config
const _config = {
config = {
backdropClass: this.backDropClasses,
scrollStrategy: this.defaultScrollStrategy,
injector,
...config,
};
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
return ref;
}
/** Opens a dialog in the side drawer */
openDrawer<R = unknown, D = unknown, C = unknown>(
component: ComponentType<C>,
config?: DialogConfig<D, DialogRef<R, C>>,
): DialogRef<R, C> {
this.activeDrawer?.close();
/**
* This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector.
* Similar to `this.open`, we get around this with mutability.
*/
this.activeDrawer = new DrawerDialogRef(this.drawerService);
const portal = new ComponentPortal(
component,
null,
this.createInjector({ data: config?.data, dialogRef: this.activeDrawer }),
);
this.activeDrawer.portal = portal;
this.drawerService.open(portal);
return this.activeDrawer;
return super.open(componentOrTemplateRef, config);
}
/**
@@ -209,7 +113,8 @@ export class DialogService {
*/
async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise<boolean> {
const dialogRef = this.openSimpleDialogRef(simpleDialogOptions);
return firstValueFrom(dialogRef.closed.pipe(map((v: boolean | undefined) => !!v)));
return firstValueFrom(dialogRef.closed);
}
/**
@@ -229,29 +134,20 @@ export class DialogService {
});
}
/** Close all open dialogs */
closeAll(): void {
return this.dialog.closeAll();
}
protected translate(translation: string | Translation, defaultKey?: string): string {
if (translation == null && defaultKey == null) {
return null;
}
/** The injector that is passed to the opened dialog */
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
return Injector.create({
providers: [
{
provide: DIALOG_DATA,
useValue: opts.data,
},
{
provide: DialogRef,
useValue: opts.dialogRef,
},
{
provide: CdkDialogRefBase,
useValue: opts.dialogRef,
},
],
parent: this.injector,
});
if (translation == null) {
return this.i18nService.t(defaultKey);
}
// Translation interface use implies we must localize.
if (typeof translation === "object") {
return this.i18nService.t(translation.key, ...(translation.placeholders ?? []));
}
return translation;
}
}

View File

@@ -1,22 +1,12 @@
@let isDrawer = dialogRef?.isDrawer;
<section
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
[ngClass]="[width, isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-xl']"
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-text-main"
[ngClass]="width"
@fadeIn
cdkTrapFocus
cdkTrapFocusAutoCapture
>
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
<header
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
[ngClass]="{
'tw-p-4': !isDrawer,
'tw-p-6 tw-pb-4': isDrawer,
'tw-border-secondary-300': showHeaderBorder,
'tw-border-transparent': !showHeaderBorder,
}"
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid tw-border-secondary-300 tw-p-4"
>
<h2
<h1
bitDialogTitleContainer
bitTypography="h3"
noMargin
@@ -29,7 +19,7 @@
</span>
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h2>
</h1>
<button
type="button"
bitIconButton="bwi-close"
@@ -42,11 +32,9 @@
</header>
<div
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
class="tw-relative tw-flex tw-flex-col tw-overflow-hidden"
[ngClass]="{
'tw-min-h-60': loading,
'tw-bg-background': background === 'default',
'tw-bg-background-alt': background === 'alt',
}"
>
@if (loading) {
@@ -55,28 +43,20 @@
</div>
}
<div
cdkScrollable
[ngClass]="{
'tw-p-4': !disablePadding && !isDrawer,
'tw-px-6 tw-py-4': !disablePadding && isDrawer,
'tw-p-4': !disablePadding,
'tw-overflow-y-auto': !loading,
'tw-invisible tw-overflow-y-hidden': loading,
'tw-bg-background': background === 'default',
'tw-bg-background-alt': background === 'alt',
}"
>
<ng-content select="[bitDialogContent]"></ng-content>
</div>
</div>
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
<footer
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
[ngClass]="{
'tw-px-6 tw-py-4': isDrawer,
'tw-p-4': !isDrawer,
'tw-border-secondary-300': showFooterBorder,
'tw-border-transparent': !showFooterBorder,
}"
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-4"
>
<ng-content select="[bitDialogFooter]"></ng-content>
</footer>

View File

@@ -1,18 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { CdkScrollable } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core";
import { Component, HostBinding, Input } from "@angular/core";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
import { TypographyDirective } from "../../typography/typography.directive";
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
import { fadeIn } from "../animations";
import { DialogRef } from "../dialog.service";
import { DialogCloseDirective } from "../directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
@@ -21,9 +17,6 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
templateUrl: "./dialog.component.html",
animations: [fadeIn],
standalone: true,
host: {
"(keydown.esc)": "handleEsc($event)",
},
imports: [
CommonModule,
DialogTitleContainerDirective,
@@ -31,15 +24,9 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
BitIconButtonComponent,
DialogCloseDirective,
I18nPipe,
CdkTrapFocus,
CdkScrollable,
],
})
export class DialogComponent {
protected dialogRef = inject(DialogRef, { optional: true });
private scrollableBody = viewChild.required(CdkScrollable);
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
/** Background color */
@Input()
background: "default" | "alt" = "default";
@@ -77,31 +64,21 @@ export class DialogComponent {
@HostBinding("class") get classes() {
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
return ["tw-flex", "tw-flex-col", "tw-w-screen"]
.concat(
this.width,
this.dialogRef?.isDrawer
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
: ["tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"],
)
.flat();
}
handleEsc(event: Event) {
this.dialogRef?.close();
event.stopPropagation();
return ["tw-flex", "tw-flex-col", "tw-w-screen", "tw-p-4", "tw-max-h-[90vh]"].concat(
this.width,
);
}
get width() {
switch (this.dialogSize) {
case "small": {
return "md:tw-max-w-sm";
return "tw-max-w-sm";
}
case "large": {
return "md:tw-max-w-3xl";
return "tw-max-w-3xl";
}
default: {
return "md:tw-max-w-xl";
return "tw-max-w-xl";
}
}
}

View File

@@ -22,9 +22,6 @@ For alerts or simple confirmation actions, like speedbumps, use the
Dialogs's should be used sparingly as they do call extra attention to themselves and can be
interruptive if overused.
For non-blocking, supplementary content, open dialogs as a
[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`).
## Placement
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to

View File

@@ -1,4 +1,4 @@
export * from "./dialog.module";
export * from "./simple-dialog/types";
export * from "./dialog.service";
export { DIALOG_DATA } from "@angular/cdk/dialog";
export { DialogConfig, DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";

View File

@@ -1,7 +1,7 @@
import { CdkScrollable } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { hasScrolledFrom } from "../utils/has-scrolled-from";
import { ChangeDetectionStrategy, Component, Signal, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { map } from "rxjs";
/**
* Body container for `bit-drawer`
@@ -14,7 +14,7 @@ import { hasScrolledFrom } from "../utils/has-scrolled-from";
host: {
class:
"tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
"[class.tw-border-t-secondary-300]": "isScrolled()",
},
hostDirectives: [
{
@@ -24,5 +24,13 @@ import { hasScrolledFrom } from "../utils/has-scrolled-from";
template: ` <ng-content></ng-content> `,
})
export class DrawerBodyComponent {
protected hasScrolledFrom = hasScrolledFrom();
private scrollable = inject(CdkScrollable);
/** TODO: share this utility with browser popup header? */
protected isScrolled: Signal<boolean> = toSignal(
this.scrollable
.elementScrolled()
.pipe(map(() => this.scrollable.measureScrollOffset("top") > 0)),
{ initialValue: false },
);
}

View File

@@ -10,7 +10,7 @@ import {
viewChild,
} from "@angular/core";
import { DrawerService } from "./drawer.service";
import { DrawerHostDirective } from "./drawer-host.directive";
/**
* A drawer is a panel of supplementary content that is adjacent to the page's main content.
@@ -25,7 +25,7 @@ import { DrawerService } from "./drawer.service";
templateUrl: "drawer.component.html",
})
export class DrawerComponent {
private drawerHost = inject(DrawerService);
private drawerHost = inject(DrawerHostDirective);
private portal = viewChild.required(CdkPortal);
/**

View File

@@ -12,8 +12,6 @@ import { DrawerComponent } from "@bitwarden/components";
# Drawer
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
A drawer is a panel of supplementary content that is adjacent to the page's main content.
<Primary />

View File

@@ -1,20 +0,0 @@
import { Portal } from "@angular/cdk/portal";
import { Injectable, signal } from "@angular/core";
@Injectable({ providedIn: "root" })
export class DrawerService {
private _portal = signal<Portal<unknown> | undefined>(undefined);
/** The portal to display */
portal = this._portal.asReadonly();
open(portal: Portal<unknown>) {
this._portal.set(portal);
}
close(portal: Portal<unknown>) {
if (portal === this.portal()) {
this._portal.set(undefined);
}
}
}

View File

@@ -1,2 +1 @@
export * from "./layout.component";
export * from "./scroll-layout.directive";

View File

@@ -1,52 +1,43 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-w-full">
<div class="tw-flex tw-w-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
#main
[id]="mainContentId"
tabindex="-1"
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ml-0 tw-ml-16"
>
<ng-content></ng-content>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div
(click)="sideNavService.toggle()"
class="tw-pointer-events-auto tw-size-full"
></div>
}
</div>
}
</main>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>
</nav>
</div>
<div class="tw-flex tw-w-full">
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
[id]="mainContentId"
tabindex="-1"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ml-0 tw-ml-16"
>
<ng-content></ng-content>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
</main>
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>

View File

@@ -1,10 +1,9 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { Component, inject } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerService } from "../drawer/drawer.service";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
import { LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
@@ -13,23 +12,16 @@ import { SharedModule } from "../shared";
selector: "bit-layout",
templateUrl: "layout.component.html",
standalone: true,
imports: [
CommonModule,
SharedModule,
LinkModule,
RouterModule,
PortalModule,
A11yModule,
CdkTrapFocus,
],
imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule],
hostDirectives: [DrawerHostDirective],
})
export class LayoutComponent {
protected mainContentId = "main-content";
protected sideNavService = inject(SideNavService);
protected drawerPortal = inject(DrawerService).portal;
protected drawerPortal = inject(DrawerHostDirective).portal;
private mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
protected focusMainContent() {
this.mainContent().nativeElement.focus();
focusMainContent() {
document.getElementById(this.mainContentId)?.focus();
}
}

View File

@@ -1,35 +0,0 @@
import { Directionality } from "@angular/cdk/bidi";
import { CdkVirtualScrollable, ScrollDispatcher, VIRTUAL_SCROLLABLE } from "@angular/cdk/scrolling";
import { Directive, ElementRef, NgZone, Optional } from "@angular/core";
@Directive({
selector: "cdk-virtual-scroll-viewport[bitScrollLayout]",
standalone: true,
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }],
})
export class ScrollLayoutDirective extends CdkVirtualScrollable {
private mainRef: ElementRef<HTMLElement>;
constructor(scrollDispatcher: ScrollDispatcher, ngZone: NgZone, @Optional() dir: Directionality) {
const mainEl = document.querySelector("main")!;
if (!mainEl) {
// eslint-disable-next-line no-console
console.error("HTML main element must be an ancestor of [bitScrollLayout]");
}
const mainRef = new ElementRef(mainEl);
super(mainRef, scrollDispatcher, ngZone, dir);
this.mainRef = mainRef;
}
override getElementRef(): ElementRef<HTMLElement> {
return this.mainRef;
}
override measureBoundingClientRectWithScrollOffset(
from: "left" | "top" | "right" | "bottom",
): number {
return (
this.mainRef.nativeElement.getBoundingClientRect()[from] - this.measureScrollOffset(from)
);
}
}

View File

@@ -16,11 +16,7 @@ import { filter, mergeWith } from "rxjs/operators";
import { MenuComponent } from "./menu.component";
@Directive({
selector: "[bitMenuTriggerFor]",
exportAs: "menuTrigger",
standalone: true,
})
@Directive({ selector: "[bitMenuTriggerFor]", exportAs: "menuTrigger", standalone: true })
export class MenuTriggerForDirective implements OnDestroy {
@HostBinding("attr.aria-expanded") isOpen = false;
@HostBinding("attr.aria-haspopup") get hasPopup(): "menu" | "dialog" {
@@ -42,18 +38,10 @@ export class MenuTriggerForDirective implements OnDestroy {
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions([
{
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "top",
},
{
originX: "end",
originY: "bottom",
overlayX: "end",
overlayY: "top",
},
{ originX: "start", originY: "bottom", overlayX: "start", overlayY: "top" },
{ originX: "end", originY: "bottom", overlayX: "end", overlayY: "top" },
{ originX: "start", originY: "top", overlayX: "start", overlayY: "bottom" },
{ originX: "end", originY: "top", overlayX: "end", overlayY: "bottom" },
])
.withLockedPosition(true)
.withFlexibleDimensions(false)

View File

@@ -3,23 +3,15 @@ import { Component, OnInit } from "@angular/core";
import { DialogModule, DialogService } from "../../../dialog";
import { IconButtonModule } from "../../../icon-button";
import { ScrollLayoutDirective } from "../../../layout";
import { SectionComponent } from "../../../section";
import { TableDataSource, TableModule } from "../../../table";
@Component({
selector: "dialog-virtual-scroll-block",
standalone: true,
imports: [
DialogModule,
IconButtonModule,
SectionComponent,
TableModule,
ScrollingModule,
ScrollLayoutDirective,
],
imports: [DialogModule, IconButtonModule, SectionComponent, TableModule, ScrollingModule],
template: /*html*/ `<bit-section>
<cdk-virtual-scroll-viewport bitScrollLayout itemSize="63.5">
<cdk-virtual-scroll-viewport scrollWindow itemSize="47">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>

View File

@@ -12,69 +12,8 @@ import { KitchenSinkToggleList } from "./kitchen-sink-toggle-list.component";
standalone: true,
imports: [KitchenSinkSharedModule],
template: `
<bit-dialog title="Dialog Title" dialogSize="small">
<ng-container bitDialogContent>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<bit-form-field>
<bit-label>What did foo say to bar?</bit-label>
<input bitInput value="Baz" />
</bit-form-field>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
</ng-container>
<bit-dialog title="Dialog Title" dialogSize="large">
<span bitDialogContent> Dialog body text goes here. </span>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">OK</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
@@ -151,6 +90,72 @@ class KitchenSinkDialog {
</bit-section>
</bit-tab>
</bit-tab-group>
<bit-drawer [(open)]="drawerOpen">
<bit-drawer-header title="Foo ipsum"></bit-drawer-header>
<bit-drawer-body>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<bit-form-field>
<bit-label>What did foo say to bar?</bit-label>
<input bitInput value="Baz" />
</bit-form-field>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
<p bitTypography="body1">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt
ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur
sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.
</p>
</bit-drawer-body>
</bit-drawer>
`,
})
export class KitchenSinkMainComponent {
@@ -163,7 +168,7 @@ export class KitchenSinkMainComponent {
}
openDrawer() {
this.dialogService.openDrawer(KitchenSinkDialog);
this.drawerOpen.set(true);
}
navItems = [

View File

@@ -14,6 +14,7 @@ import {
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "../../dialog";
import { LayoutComponent } from "../../layout";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { positionFixedWrapperDecorator } from "../storybook-decorators";
@@ -38,20 +39,8 @@ export default {
KitchenSinkTable,
KitchenSinkToggleList,
],
}),
applicationConfig({
providers: [
provideNoopAnimations(),
importProvidersFrom(
RouterModule.forRoot(
[
{ path: "", redirectTo: "bitwarden", pathMatch: "full" },
{ path: "bitwarden", component: KitchenSinkMainComponent },
{ path: "virtual-scroll", component: DialogVirtualScrollBlockComponent },
],
{ useHash: true },
),
),
DialogService,
{
provide: I18nService,
useFactory: () => {
@@ -69,6 +58,21 @@ export default {
},
],
}),
applicationConfig({
providers: [
provideNoopAnimations(),
importProvidersFrom(
RouterModule.forRoot(
[
{ path: "", redirectTo: "bitwarden", pathMatch: "full" },
{ path: "bitwarden", component: KitchenSinkMainComponent },
{ path: "virtual-scroll", component: DialogVirtualScrollBlockComponent },
],
{ useHash: true },
),
),
],
}),
],
} as Meta;

View File

@@ -1,5 +1,5 @@
<cdk-virtual-scroll-viewport
bitScrollLayout
scrollWindow
[itemSize]="rowSize"
[ngStyle]="{ paddingBottom: headerHeight + 'px' }"
>

View File

@@ -2,7 +2,6 @@
// @ts-strict-ignore
import {
CdkVirtualScrollViewport,
CdkVirtualScrollableWindow,
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
} from "@angular/cdk/scrolling";
@@ -21,8 +20,6 @@ import {
TrackByFunction,
} from "@angular/core";
import { ScrollLayoutDirective } from "../layout";
import { RowDirective } from "./row.directive";
import { TableComponent } from "./table.component";
@@ -56,11 +53,9 @@ export class BitRowDef {
imports: [
CommonModule,
CdkVirtualScrollViewport,
CdkVirtualScrollableWindow,
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
RowDirective,
ScrollLayoutDirective,
],
})
export class TableScrollComponent

View File

@@ -142,7 +142,7 @@ dataSource.filter = (data) => data.orgType === "family";
Rudimentary string filtering is supported out of the box with `TableDataSource.simpleStringFilter`.
It works by converting each entry into a string of it's properties. The provided string is then
compared against the filter value using a simple `indexOf` check. For convenience, you can also just
compared against the filter value using a simple `indexOf` check. For convienence, you can also just
pass a string directly.
```ts
@@ -153,7 +153,7 @@ dataSource.filter = "search value";
### Virtual Scrolling
It's heavily advised to use virtual scrolling if you expect the table to have any significant amount
It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount
of data. This is done by using the `bit-table-scroll` component instead of the `bit-table`
component. This component behaves slightly different from the `bit-table` component. Instead of
using the `*ngFor` directive to render the rows, you provide a `bitRowDef` template that will be
@@ -178,14 +178,6 @@ height and align vertically.
</bit-table-scroll>
```
#### Deprecated approach
Before `bit-table-scroll` was introduced, virtual scroll in tables was implemented manually via
constructs from Angular CDK. This included wrapping the table with a `cdk-virtual-scroll-viewport`
and targeting with `bit-layout`'s scroll container with the `bitScrollLayout` directive.
This pattern is deprecated in favor of `bit-table-scroll`.
## Accessibility
- Always include a row or column header with your table; this allows assistive technology to better

View File

@@ -1,13 +1,6 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { countries } from "../form/countries";
import { LayoutComponent } from "../layout";
import { mockLayoutI18n } from "../layout/mocks";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils";
import { TableDataSource } from "./table-data-source";
import { TableModule } from "./table.module";
@@ -15,17 +8,8 @@ import { TableModule } from "./table.module";
export default {
title: "Component Library/Table",
decorators: [
positionFixedWrapperDecorator(),
moduleMetadata({
imports: [TableModule, LayoutComponent, RouterTestingModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService(mockLayoutI18n);
},
},
],
imports: [TableModule],
}),
],
argTypes: {
@@ -132,20 +116,18 @@ export const Scrollable: Story = {
trackBy: (index: number, item: any) => item.id,
},
template: `
<bit-layout>
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
<ng-container header>
<th bitCell bitSortable="id" default>Id</th>
<th bitCell bitSortable="name">Name</th>
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>{{ row.id }}</td>
<td bitCell>{{ row.name }}</td>
<td bitCell>{{ row.other }}</td>
</ng-template>
</bit-table-scroll>
</bit-layout>
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
<ng-container header>
<th bitCell bitSortable="id" default>Id</th>
<th bitCell bitSortable="name">Name</th>
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>{{ row.id }}</td>
<td bitCell>{{ row.name }}</td>
<td bitCell>{{ row.other }}</td>
</ng-template>
</bit-table-scroll>
`,
}),
};
@@ -162,19 +144,17 @@ export const Filterable: Story = {
sortFn: (a: any, b: any) => a.id - b.id,
},
template: `
<bit-layout>
<input type="search" placeholder="Search" (input)="dataSource.filter = $event.target.value" />
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
<ng-container header>
<th bitCell bitSortable="name" default>Name</th>
<th bitCell bitSortable="value" width="120px">Value</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>{{ row.name }}</td>
<td bitCell>{{ row.value }}</td>
</ng-template>
</bit-table-scroll>
</bit-layout>
<input type="search" placeholder="Search" (input)="dataSource.filter = $event.target.value" />
<bit-table-scroll [dataSource]="dataSource" [rowSize]="43">
<ng-container header>
<th bitCell bitSortable="name" default>Name</th>
<th bitCell bitSortable="value" width="120px">Value</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>{{ row.name }}</td>
<td bitCell>{{ row.value }}</td>
</ng-template>
</bit-table-scroll>
`,
}),
};

View File

@@ -1,41 +0,0 @@
import { CdkScrollable } from "@angular/cdk/scrolling";
import { Signal, inject, signal } from "@angular/core";
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
import { map, startWith, switchMap } from "rxjs";
export type ScrollState = {
/** `true` when the scrollbar is not at the top-most position */
top: boolean;
/** `true` when the scrollbar is not at the bottom-most position */
bottom: boolean;
};
/**
* Check if a `CdkScrollable` instance has been scrolled
* @param scrollable The instance to check, defaults to the one provided by the current injector
* @returns {Signal<ScrollState>}
*/
export const hasScrolledFrom = (scrollable?: Signal<CdkScrollable>): Signal<ScrollState> => {
const _scrollable = scrollable ?? signal(inject(CdkScrollable));
const scrollable$ = toObservable(_scrollable);
const scrollState$ = scrollable$.pipe(
switchMap((_scrollable) =>
_scrollable.elementScrolled().pipe(
startWith(null),
map(() => ({
top: _scrollable.measureScrollOffset("top") > 0,
bottom: _scrollable.measureScrollOffset("bottom") > 0,
})),
),
),
);
return toSignal(scrollState$, {
initialValue: {
top: false,
bottom: false,
},
});
};

View File

@@ -49,7 +49,6 @@ import {
ButtonModule,
CalloutModule,
CardComponent,
ContainerComponent,
DialogService,
FormFieldModule,
IconButtonModule,
@@ -119,7 +118,6 @@ const safeProviders: SafeProvider[] = [
ImportLastPassComponent,
RadioButtonModule,
CardComponent,
ContainerComponent,
SectionHeaderComponent,
SectionComponent,
LinkModule,

View File

@@ -12,8 +12,4 @@ export enum BiometricsCommands {
/** Checks whether the biometric unlock can be enabled. */
CanEnableBiometricUnlock = "canEnableBiometricUnlock",
// legacy
Unlock = "biometricUnlock",
IsAvailable = "biometricUnlockAvailable",
}

View File

@@ -80,7 +80,6 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
CalloutModule,
RadioButtonModule,
ExportScopeCalloutComponent,
UserVerificationDialogComponent,
PasswordStrengthV2Component,
GeneratorServicesModule,
],

View File

@@ -25,10 +25,8 @@ import {
AsyncActionsModule,
BitSubmitDirective,
ButtonComponent,
CardComponent,
FormFieldModule,
ItemModule,
SectionComponent,
SelectModule,
ToastService,
TypographyModule,
@@ -52,8 +50,6 @@ import { SendDetailsComponent } from "./send-details/send-details.component";
],
imports: [
AsyncActionsModule,
CardComponent,
SectionComponent,
TypographyModule,
ItemModule,
FormFieldModule,

View File

@@ -13,7 +13,6 @@ import {
CheckboxModule,
FormFieldModule,
LinkModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -28,7 +27,6 @@ import { CustomFieldsComponent } from "../custom-fields/custom-fields.component"
standalone: true,
imports: [
CommonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
JslibModule,

View File

@@ -22,7 +22,6 @@ import {
FormFieldModule,
IconButtonModule,
LinkModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
@@ -43,7 +42,6 @@ interface UriField {
standalone: true,
imports: [
DragDropModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
JslibModule,

View File

@@ -16,7 +16,6 @@ import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
@@ -30,7 +29,6 @@ import { CipherFormContainer } from "../../cipher-form-container";
standalone: true,
imports: [
CardComponent,
SectionComponent,
TypographyModule,
FormFieldModule,
ReactiveFormsModule,

View File

@@ -26,10 +26,8 @@ import {
AsyncActionsModule,
BitSubmitDirective,
ButtonComponent,
CardComponent,
FormFieldModule,
ItemModule,
SectionComponent,
SelectModule,
ToastService,
TypographyModule,
@@ -63,8 +61,6 @@ import { SshKeySectionComponent } from "./sshkey-section/sshkey-section.componen
],
imports: [
AsyncActionsModule,
CardComponent,
SectionComponent,
TypographyModule,
ItemModule,
FormFieldModule,

View File

@@ -37,7 +37,6 @@ import {
FormFieldModule,
IconButtonModule,
LinkModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
@@ -79,7 +78,6 @@ export type CustomField = {
FormsModule,
FormFieldModule,
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
CardComponent,

View File

@@ -14,7 +14,6 @@ import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
@@ -31,7 +30,6 @@ import { CipherFormContainer } from "../../cipher-form-container";
ButtonModule,
JslibModule,
ReactiveFormsModule,
SectionComponent,
SectionHeaderComponent,
CardComponent,
FormFieldModule,

View File

@@ -19,7 +19,6 @@ import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
SelectItemView,
SelectModule,
@@ -38,7 +37,6 @@ import { CipherFormContainer } from "../../cipher-form-container";
standalone: true,
imports: [
CardComponent,
SectionComponent,
TypographyModule,
FormFieldModule,
ReactiveFormsModule,

View File

@@ -20,7 +20,6 @@ import {
IconButtonModule,
LinkModule,
PopoverModule,
SectionComponent,
SectionHeaderComponent,
ToastService,
TypographyModule,
@@ -36,7 +35,6 @@ import { AutofillOptionsComponent } from "../autofill-options/autofill-options.c
templateUrl: "./login-details-section.component.html",
standalone: true,
imports: [
SectionComponent,
ReactiveFormsModule,
SectionHeaderComponent,
TypographyModule,

View File

@@ -16,7 +16,6 @@ import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
SelectModule,
TypographyModule,
@@ -32,7 +31,6 @@ import { CipherFormContainer } from "../../cipher-form-container";
standalone: true,
imports: [
CardComponent,
SectionComponent,
TypographyModule,
FormFieldModule,
ReactiveFormsModule,

View File

@@ -6,7 +6,6 @@ import {
IconButtonModule,
CardComponent,
InputModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,
@@ -22,7 +21,6 @@ import {
CardComponent,
IconButtonModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,

View File

@@ -15,7 +15,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -32,7 +31,6 @@ import { DownloadAttachmentComponent } from "../../components/download-attachmen
JslibModule,
ItemModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
DownloadAttachmentComponent,

View File

@@ -14,7 +14,6 @@ import {
CardComponent,
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -27,7 +26,6 @@ import {
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,

View File

@@ -9,8 +9,6 @@ import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,
@@ -26,8 +24,6 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-
imports: [
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,

View File

@@ -17,7 +17,6 @@ import {
IconButtonModule,
FormFieldModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
CheckboxModule,
@@ -37,7 +36,6 @@ import { VaultAutosizeReadOnlyTextArea } from "../../directives/readonly-textare
IconButtonModule,
FormFieldModule,
InputModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
CheckboxModule,

View File

@@ -11,7 +11,6 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
CardComponent,
FormFieldModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -26,7 +25,6 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
OrgIconDirective,

View File

@@ -23,7 +23,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
FormFieldModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
LinkModule,
@@ -47,7 +46,6 @@ type TotpCodeValues = {
imports: [
CommonModule,
JslibModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,

View File

@@ -7,15 +7,12 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import {
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,
IconButtonModule,
} from "@bitwarden/components";
import { OrgIconDirective } from "../../components/org-icon.directive";
@Component({
selector: "app-sshkey-view",
templateUrl: "sshkey-view.component.html",
@@ -24,10 +21,8 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
CommonModule,
JslibModule,
CardComponent,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
OrgIconDirective,
FormFieldModule,
IconButtonModule,
],

View File

@@ -6,7 +6,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
FormFieldModule,
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -20,7 +19,6 @@ import { ReadOnlyCipherCardComponent } from "../read-only-cipher-card/read-only-
imports: [
NgIf,
JslibModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
FormFieldModule,