1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 20:50:28 +00:00

Merge branch 'main' into desktop/pm-18769/migrate-vault-filters

This commit is contained in:
Leslie Xiong
2026-01-02 12:40:17 -05:00
181 changed files with 6039 additions and 1846 deletions

View File

@@ -1146,6 +1146,10 @@ const safeProviders: SafeProvider[] = [
KeyGenerationService,
LOGOUT_CALLBACK,
StateProvider,
ConfigService,
RegisterSdkService,
SecurityStateService,
AccountCryptographicStateService,
],
}),
safeProvider({

View File

@@ -1,3 +1,4 @@
// Note: Nudge related code is exported from `libs/angular` because it is consumed by multiple
// `libs/*` packages. Exporting from the `libs/vault` package creates circular dependencies.
export { NudgesService, NudgeStatus, NudgeType } from "./services/nudges.service";
export { AUTOFILL_NUDGE_SERVICE } from "./services/nudge-injection-tokens";

View File

@@ -4,3 +4,4 @@ export * from "./empty-vault-nudge.service";
export * from "./vault-settings-import-nudge.service";
export * from "./new-item-nudge.service";
export * from "./new-account-nudge.service";
export * from "./noop-nudge.service";

View File

@@ -0,0 +1,27 @@
import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { SingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
/**
* A no-op nudge service that always returns dismissed status.
* Use this for nudges that should be completely ignored/hidden in certain clients.
* For example, browser-specific nudges can use this as the default in non-browser clients.
*/
@Injectable({ providedIn: "root" })
export class NoOpNudgeService implements SingleNudgeService {
nudgeStatus$(_nudgeType: NudgeType, _userId: UserId): Observable<NudgeStatus> {
return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true });
}
async setNudgeStatus(
_nudgeType: NudgeType,
_newStatus: NudgeStatus,
_userId: UserId,
): Promise<void> {
// No-op: state changes are ignored
}
}

View File

@@ -0,0 +1,7 @@
import { InjectionToken } from "@angular/core";
import { SingleNudgeService } from "./default-single-nudge.service";
export const AUTOFILL_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutofillNudgeService",
);

View File

@@ -12,8 +12,10 @@ import {
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
NoOpNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
import { AUTOFILL_NUDGE_SERVICE } from "./nudge-injection-tokens";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
@@ -56,6 +58,12 @@ export class NudgesService {
private newItemNudgeService = inject(NewItemNudgeService);
private newAcctNudgeService = inject(NewAccountNudgeService);
// NoOp service that always returns dismissed
private noOpNudgeService = inject(NoOpNudgeService);
// Optional Browser-specific service provided via injection token (not all clients have autofill)
private autofillNudgeService = inject(AUTOFILL_NUDGE_SERVICE, { optional: true });
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
@@ -66,7 +74,7 @@ export class NudgesService {
[NudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService),
[NudgeType.VaultSettingsImportNudge]: inject(VaultSettingsImportNudgeService),
[NudgeType.AccountSecurity]: inject(AccountSecurityNudgeService),
[NudgeType.AutofillNudge]: this.newAcctNudgeService,
[NudgeType.AutofillNudge]: this.autofillNudgeService ?? this.noOpNudgeService,
[NudgeType.DownloadBitwarden]: this.newAcctNudgeService,
[NudgeType.GeneratorNudgeStatus]: this.newAcctNudgeService,
[NudgeType.NewLoginItemStatus]: this.newItemNudgeService,

View File

@@ -0,0 +1,377 @@
// Mock asUuid to return the input value for test consistency
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk.service", () => ({
asUuid: (x: any) => x,
}));
import { DestroyRef } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import {
LoginEmailServiceAbstraction,
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import { SignedSecurityState } from "@bitwarden/common/key-management/types";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { AnonLayoutWrapperDataService, DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LoginDecryptionOptionsComponent } from "./login-decryption-options.component";
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
describe("LoginDecryptionOptionsComponent", () => {
let component: LoginDecryptionOptionsComponent;
let accountService: MockProxy<AccountService>;
let anonLayoutWrapperDataService: MockProxy<AnonLayoutWrapperDataService>;
let apiService: MockProxy<ApiService>;
let destroyRef: MockProxy<DestroyRef>;
let deviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
let dialogService: MockProxy<DialogService>;
let formBuilder: FormBuilder;
let i18nService: MockProxy<I18nService>;
let keyService: MockProxy<KeyService>;
let loginDecryptionOptionsService: MockProxy<LoginDecryptionOptionsService>;
let loginEmailService: MockProxy<LoginEmailServiceAbstraction>;
let messagingService: MockProxy<MessagingService>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let passwordResetEnrollmentService: MockProxy<PasswordResetEnrollmentServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let router: MockProxy<Router>;
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let toastService: MockProxy<ToastService>;
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
let validationService: MockProxy<ValidationService>;
let logoutService: MockProxy<LogoutService>;
let registerSdkService: MockProxy<RegisterSdkService>;
let securityStateService: MockProxy<SecurityStateService>;
let appIdService: MockProxy<AppIdService>;
let configService: MockProxy<ConfigService>;
let accountCryptographicStateService: MockProxy<any>;
const mockUserId = "user-id-123" as UserId;
const mockEmail = "test@example.com";
const mockOrgId = "org-id-456";
beforeEach(() => {
accountService = mock<AccountService>();
anonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
apiService = mock<ApiService>();
destroyRef = mock<DestroyRef>();
deviceTrustService = mock<DeviceTrustServiceAbstraction>();
dialogService = mock<DialogService>();
formBuilder = new FormBuilder();
i18nService = mock<I18nService>();
keyService = mock<KeyService>();
loginDecryptionOptionsService = mock<LoginDecryptionOptionsService>();
loginEmailService = mock<LoginEmailServiceAbstraction>();
messagingService = mock<MessagingService>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
passwordResetEnrollmentService = mock<PasswordResetEnrollmentServiceAbstraction>();
platformUtilsService = mock<PlatformUtilsService>();
router = mock<Router>();
ssoLoginService = mock<SsoLoginServiceAbstraction>();
toastService = mock<ToastService>();
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
validationService = mock<ValidationService>();
logoutService = mock<LogoutService>();
registerSdkService = mock<RegisterSdkService>();
securityStateService = mock<SecurityStateService>();
appIdService = mock<AppIdService>();
configService = mock<ConfigService>();
accountCryptographicStateService = mock();
// Setup default mocks
accountService.activeAccount$ = new BehaviorSubject({
id: mockUserId,
email: mockEmail,
name: "Test User",
emailVerified: true,
creationDate: new Date(),
});
platformUtilsService.getClientType.mockReturnValue(ClientType.Browser);
deviceTrustService.getShouldTrustDevice.mockResolvedValue(true);
i18nService.t.mockImplementation((key: string) => key);
component = new LoginDecryptionOptionsComponent(
accountService,
anonLayoutWrapperDataService,
apiService,
destroyRef,
deviceTrustService,
dialogService,
formBuilder,
i18nService,
keyService,
loginDecryptionOptionsService,
loginEmailService,
messagingService,
organizationApiService,
passwordResetEnrollmentService,
platformUtilsService,
router,
ssoLoginService,
toastService,
userDecryptionOptionsService,
validationService,
logoutService,
registerSdkService,
securityStateService,
appIdService,
configService,
accountCryptographicStateService,
);
});
describe("createUser with feature flag enabled", () => {
let mockPostKeysForTdeRegistration: jest.Mock;
let mockRegistration: any;
let mockAuth: any;
let mockSdkValue: any;
let mockSdkRef: any;
let mockSdk: any;
let mockDeviceKey: string;
let mockDeviceKeyObj: SymmetricCryptoKey;
let mockUserKeyBytes: Uint8Array;
let mockPrivateKey: string;
let mockSignedPublicKey: string;
let mockSigningKey: string;
let mockSecurityState: SignedSecurityState;
beforeEach(async () => {
// Mock asUuid to return the input value for test consistency
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk.service", () => ({
asUuid: (x: any) => x,
}));
(Symbol as any).dispose = Symbol("dispose");
mockPrivateKey = "mock-private-key";
mockSignedPublicKey = "mock-signed-public-key";
mockSigningKey = "mock-signing-key";
mockSecurityState = {
signature: "mock-signature",
payload: {
version: 2,
timestamp: Date.now(),
privateKeyHash: "mock-hash",
},
} as any;
const deviceKeyBytes = new Uint8Array(32).fill(5);
mockDeviceKey = Buffer.from(deviceKeyBytes).toString("base64");
mockDeviceKeyObj = SymmetricCryptoKey.fromString(mockDeviceKey);
mockUserKeyBytes = new Uint8Array(64);
mockPostKeysForTdeRegistration = jest.fn().mockResolvedValue({
account_cryptographic_state: {
V2: {
private_key: mockPrivateKey,
signed_public_key: mockSignedPublicKey,
signing_key: mockSigningKey,
security_state: mockSecurityState,
},
},
device_key: mockDeviceKey,
user_key: mockUserKeyBytes,
});
mockRegistration = {
post_keys_for_tde_registration: mockPostKeysForTdeRegistration,
};
mockAuth = {
registration: jest.fn().mockReturnValue(mockRegistration),
};
mockSdkValue = {
auth: jest.fn().mockReturnValue(mockAuth),
};
mockSdkRef = {
value: mockSdkValue,
[Symbol.dispose]: jest.fn(),
};
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
// Setup for new user state
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
trustedDeviceOption: {
hasAdminApproval: false,
hasLoginApprovingDevice: false,
hasManageResetPasswordPermission: false,
isTdeOffboarding: false,
},
hasMasterPassword: false,
keyConnectorOption: undefined,
}),
);
ssoLoginService.getActiveUserOrganizationSsoIdentifier.mockResolvedValue("org-identifier");
organizationApiService.getAutoEnrollStatus.mockResolvedValue({
id: mockOrgId,
resetPasswordEnabled: true,
} as any);
// Initialize component to set up new user state
await component.ngOnInit();
});
it("should use SDK v2 registration when feature flag is enabled", async () => {
// Arrange
configService.getFeatureFlag.mockResolvedValue(true);
loginDecryptionOptionsService.handleCreateUserSuccess.mockResolvedValue(undefined);
router.navigate.mockResolvedValue(true);
appIdService.getAppId.mockResolvedValue("mock-app-id");
organizationApiService.getKeys.mockResolvedValue({
publicKey: "mock-org-public-key",
privateKey: "mock-org-private-key",
} as any);
// Act
await component["createUser"]();
// Assert
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM27279_V2RegistrationTdeJit,
);
expect(appIdService.getAppId).toHaveBeenCalled();
expect(organizationApiService.getKeys).toHaveBeenCalledWith(mockOrgId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId);
// Verify SDK registration was called with correct parameters
expect(mockSdkValue.auth).toHaveBeenCalled();
expect(mockAuth.registration).toHaveBeenCalled();
expect(mockPostKeysForTdeRegistration).toHaveBeenCalledWith({
org_id: mockOrgId,
org_public_key: "mock-org-public-key",
user_id: mockUserId,
device_identifier: "mock-app-id",
trust_device: true,
});
const expectedDeviceKey = mockDeviceKeyObj;
const expectedUserKey = new SymmetricCryptoKey(new Uint8Array(mockUserKeyBytes));
// Verify keys were set
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
mockSecurityState,
mockUserId,
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
expect.objectContaining({
V2: {
private_key: mockPrivateKey,
signed_public_key: mockSignedPublicKey,
signing_key: mockSigningKey,
security_state: mockSecurityState,
},
}),
mockUserId,
);
expect(validationService.showError).not.toHaveBeenCalled();
// Verify device and user keys were persisted
expect(deviceTrustService.setDeviceKey).toHaveBeenCalledWith(
mockUserId,
expect.any(SymmetricCryptoKey),
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
const [, deviceKeyArg] = deviceTrustService.setDeviceKey.mock.calls[0];
const [userKeyArg] = keyService.setUserKey.mock.calls[0];
expect((deviceKeyArg as SymmetricCryptoKey).keyB64).toBe(expectedDeviceKey.keyB64);
expect((userKeyArg as SymmetricCryptoKey).keyB64).toBe(expectedUserKey.keyB64);
// Verify success toast and navigation
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: null,
message: "accountSuccessfullyCreated",
});
expect(loginDecryptionOptionsService.handleCreateUserSuccess).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
it("should use legacy registration when feature flag is disabled", async () => {
// Arrange
configService.getFeatureFlag.mockResolvedValue(false);
const mockPublicKey = "mock-public-key";
const mockPrivateKey = {
encryptedString: "mock-encrypted-private-key",
} as any;
keyService.initAccount.mockResolvedValue({
publicKey: mockPublicKey,
privateKey: mockPrivateKey,
} as any);
apiService.postAccountKeys.mockResolvedValue(undefined);
passwordResetEnrollmentService.enroll.mockResolvedValue(undefined);
deviceTrustService.trustDevice.mockResolvedValue(undefined);
loginDecryptionOptionsService.handleCreateUserSuccess.mockResolvedValue(undefined);
router.navigate.mockResolvedValue(true);
// Act
await component["createUser"]();
// Assert
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM27279_V2RegistrationTdeJit,
);
expect(keyService.initAccount).toHaveBeenCalledWith(mockUserId);
expect(apiService.postAccountKeys).toHaveBeenCalledWith(
expect.objectContaining({
publicKey: mockPublicKey,
encryptedPrivateKey: mockPrivateKey.encryptedString,
}),
);
expect(passwordResetEnrollmentService.enroll).toHaveBeenCalledWith(mockOrgId);
expect(deviceTrustService.trustDevice).toHaveBeenCalledWith(mockUserId);
// Verify success toast
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: null,
message: "accountSuccessfullyCreated",
});
// Verify navigation
expect(loginDecryptionOptionsService.handleCreateUserSuccess).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
});
});

View File

@@ -5,7 +5,17 @@ import { Component, DestroyRef, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms";
import { Router } from "@angular/router";
import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError } from "rxjs";
import {
catchError,
concatMap,
defer,
firstValueFrom,
from,
map,
of,
switchMap,
throwError,
} from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -20,13 +30,27 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import {
SignedPublicKey,
SignedSecurityState,
WrappedSigningKey,
} from "@bitwarden/common/key-management/types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { DeviceKey, UserKey } from "@bitwarden/common/types/key";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import {
@@ -40,6 +64,7 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal";
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
@@ -112,6 +137,11 @@ export class LoginDecryptionOptionsComponent implements OnInit {
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private validationService: ValidationService,
private logoutService: LogoutService,
private registerSdkService: RegisterSdkService,
private securityStateService: SecurityStateService,
private appIdService: AppIdService,
private configService: ConfigService,
private accountCryptographicStateService: AccountCryptographicStateService,
) {
this.clientType = this.platformUtilsService.getClientType();
}
@@ -251,9 +281,85 @@ export class LoginDecryptionOptionsComponent implements OnInit {
}
try {
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
await this.apiService.postAccountKeys(keysRequest);
const useSdkV2Creation = await this.configService.getFeatureFlag(
FeatureFlag.PM27279_V2RegistrationTdeJit,
);
if (useSdkV2Creation) {
const deviceIdentifier = await this.appIdService.getAppId();
const userId = this.activeAccountId;
const organizationId = this.newUserOrgId;
const orgKeyResponse = await this.organizationApiService.getKeys(organizationId);
const register_result = await firstValueFrom(
this.registerSdkService.registerClient$(userId).pipe(
concatMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return await ref.value
.auth()
.registration()
.post_keys_for_tde_registration({
org_id: asUuid<SdkOrganizationId>(organizationId),
org_public_key: orgKeyResponse.publicKey,
user_id: asUuid<SdkUserId>(userId),
device_identifier: deviceIdentifier,
trust_device: this.formGroup.value.rememberDevice,
});
}),
),
);
// The keys returned here can only be v2 keys, since the SDK only implements returning V2 keys.
if ("V1" in register_result.account_cryptographic_state) {
throw new Error("Unexpected V1 account cryptographic state");
}
// Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration
// Set account cryptography state
await this.accountCryptographicStateService.setAccountCryptographicState(
register_result.account_cryptographic_state,
userId,
);
// Legacy individual states
await this.keyService.setPrivateKey(
register_result.account_cryptographic_state.V2.private_key,
userId,
);
await this.keyService.setSignedPublicKey(
register_result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
userId,
);
await this.keyService.setUserSigningKey(
register_result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
userId,
);
await this.securityStateService.setAccountSecurityState(
register_result.account_cryptographic_state.V2.security_state as SignedSecurityState,
userId,
);
// TDE unlock
await this.deviceTrustService.setDeviceKey(
userId,
SymmetricCryptoKey.fromString(register_result.device_key) as DeviceKey,
);
// Set user key - user is now unlocked
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(register_result.user_key) as UserKey,
userId,
);
} else {
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
await this.apiService.postAccountKeys(keysRequest);
await this.passwordResetEnrollmentService.enroll(this.newUserOrgId);
if (this.formGroup.value.rememberDevice) {
await this.deviceTrustService.trustDevice(this.activeAccountId);
}
}
this.toastService.showToast({
variant: "success",
@@ -261,12 +367,6 @@ export class LoginDecryptionOptionsComponent implements OnInit {
message: this.i18nService.t("accountSuccessfullyCreated"),
});
await this.passwordResetEnrollmentService.enroll(this.newUserOrgId);
if (this.formGroup.value.rememberDevice) {
await this.deviceTrustService.trustDevice(this.activeAccountId);
}
await this.loginDecryptionOptionsService.handleCreateUserSuccess();
if (this.clientType === ClientType.Desktop) {

View File

@@ -15,7 +15,7 @@ export function mockAccountInfoWith(info: Partial<AccountInfo> = {}): AccountInf
name: "name",
email: "email",
emailVerified: true,
creationDate: "2024-01-01T00:00:00.000Z",
creationDate: new Date("2024-01-01T00:00:00.000Z"),
...info,
};
}
@@ -111,7 +111,7 @@ export class FakeAccountService implements AccountService {
await this.mock.setAccountEmailVerified(userId, emailVerified);
}
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
await this.mock.setAccountCreationDate(userId, creationDate);
}

View File

@@ -2,33 +2,25 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
/**
* Holds state that represents a user's account with Bitwarden.
* Any additions here should be added to the equality check in the AccountService
* to ensure that emissions are done on every change.
*
* @property email - User's email address.
* @property emailVerified - Whether the email has been verified.
* @property name - User's display name (optional).
* @property creationDate - Date when the account was created.
* Will be undefined immediately after login until the first sync completes.
*/
export type AccountInfo = {
email: string;
emailVerified: boolean;
name: string | undefined;
creationDate: string | undefined;
creationDate: Date | undefined;
};
export type Account = { id: UserId } & AccountInfo;
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
for (const key of keys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
export abstract class AccountService {
abstract accounts$: Observable<Record<UserId, AccountInfo>>;
@@ -77,7 +69,7 @@ export abstract class AccountService {
* @param userId
* @param creationDate
*/
abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise<void>;
abstract setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId

View File

@@ -19,9 +19,21 @@ export class IdentityTokenResponse extends BaseResponse {
tokenType: string;
// Decryption Information
privateKey: string; // userKeyEncryptedPrivateKey
/**
* privateKey is actually userKeyEncryptedPrivateKey
* @deprecated Use {@link accountKeysResponseModel} instead
*/
privateKey: string;
// TODO: https://bitwarden.atlassian.net/browse/PM-30124 - Rename to just accountKeys
accountKeysResponseModel: PrivateKeysResponseModel | null = null;
key?: EncString; // masterKeyEncryptedUserKey
/**
* key is actually masterKeyEncryptedUserKey
* @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead
*/
key?: EncString;
twoFactorToken: string;
kdfConfig: KdfConfig;
forcePasswordReset: boolean;

View File

@@ -17,7 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import { AccountInfo } from "../abstractions/account.service";
import {
ACCOUNT_ACCOUNTS,
@@ -27,63 +27,6 @@ import {
AccountServiceImplementation,
} from "./account.service";
describe("accountInfoEqual", () => {
const accountInfo = mockAccountInfoWith();
it("compares nulls", () => {
expect(accountInfoEqual(null, null)).toBe(true);
expect(accountInfoEqual(null, accountInfo)).toBe(false);
expect(accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares all keys, not just those defined in AccountInfo", () => {
const different = { ...accountInfo, extra: "extra" };
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares creationDate", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares undefined creationDate", () => {
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
const same = { ...accountWithoutCreationDate };
const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" };
expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
});
});
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
@@ -121,6 +64,60 @@ describe("accountService", () => {
jest.resetAllMocks();
});
describe("accountInfoEqual", () => {
const accountInfo = mockAccountInfoWith();
it("compares nulls", () => {
expect((sut as any).accountInfoEqual(null, null)).toBe(true);
expect((sut as any).accountInfoEqual(null, accountInfo)).toBe(false);
expect((sut as any).accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares creationDate", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, creationDate: new Date("2024-12-31T00:00:00.000Z") };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares undefined creationDate", () => {
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
const same = { ...accountWithoutCreationDate };
const different = {
...accountWithoutCreationDate,
creationDate: new Date("2024-01-01T00:00:00.000Z"),
};
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
});
});
describe("activeAccount$", () => {
it("should emit null if no account is active", () => {
const emissions = trackEmissions(sut.activeAccount$);
@@ -281,7 +278,7 @@ describe("accountService", () => {
});
it("should update the account with a new creation date", async () => {
const newCreationDate = "2024-12-31T00:00:00.000Z";
const newCreationDate = new Date("2024-12-31T00:00:00.000Z");
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
@@ -297,6 +294,24 @@ describe("accountService", () => {
expect(currentState).toEqual(initialState);
});
it("should not update if the creation date has the same timestamp but different Date object", async () => {
const sameTimestamp = new Date(userInfo.creationDate.getTime());
await sut.setAccountCreationDate(userId, sameTimestamp);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
it("should update if the creation date has a different timestamp", async () => {
const differentDate = new Date(userInfo.creationDate.getTime() + 1000);
await sut.setAccountCreationDate(userId, differentDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: differentDate },
});
});
it("should update from undefined to a defined creation date", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
@@ -304,7 +319,7 @@ describe("accountService", () => {
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
const newCreationDate = "2024-06-15T12:30:00.000Z";
const newCreationDate = new Date("2024-06-15T12:30:00.000Z");
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
@@ -313,14 +328,19 @@ describe("accountService", () => {
});
});
it("should update to a different creation date string format", async () => {
const newCreationDate = "2023-03-15T08:45:30.123Z";
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: newCreationDate },
it("should not update when both creation dates are undefined", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
creationDate: undefined,
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
// Attempt to set to undefined (shouldn't trigger update)
const currentStateBefore = await firstValueFrom(accountsState.state$);
// We can't directly call setAccountCreationDate with undefined, but we can verify
// the behavior through setAccountInfo which accountInfoEqual uses internally
expect(currentStateBefore[userId].creationDate).toBeUndefined();
});
});

View File

@@ -18,7 +18,6 @@ import {
Account,
AccountInfo,
InternalAccountService,
accountInfoEqual,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -37,7 +36,10 @@ export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_DISK,
"accounts",
{
deserializer: (accountInfo) => accountInfo,
deserializer: (accountInfo) => ({
...accountInfo,
creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined,
}),
},
);
@@ -111,7 +113,7 @@ export class AccountServiceImplementation implements InternalAccountService {
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.accountActivity$ = this.globalStateProvider
@@ -168,7 +170,7 @@ export class AccountServiceImplementation implements InternalAccountService {
await this.setAccountInfo(userId, { emailVerified });
}
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
await this.setAccountInfo(userId, { creationDate });
}
@@ -274,6 +276,23 @@ export class AccountServiceImplementation implements InternalAccountService {
this._showHeader$.next(visible);
}
private accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
return (
a.email === b.email &&
a.emailVerified === b.emailVerified &&
a.name === b.name &&
a.creationDate?.getTime() === b.creationDate?.getTime()
);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };
@@ -291,7 +310,7 @@ export class AccountServiceImplementation implements InternalAccountService {
throw new Error("Account does not exist");
}
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
},
},
);

View File

@@ -26,7 +26,6 @@ export enum FeatureFlag {
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
@@ -45,6 +44,8 @@ export enum FeatureFlag {
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
@@ -63,7 +64,6 @@ export enum FeatureFlag {
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
@@ -71,8 +71,6 @@ export enum FeatureFlag {
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
@@ -126,7 +124,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
[FeatureFlag.AutofillConfirmation]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
@@ -137,7 +134,6 @@ export const DefaultFeatureFlagValue = {
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
@@ -156,11 +152,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.InactiveUserServerNotification]: FALSE,
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,

View File

@@ -39,6 +39,7 @@ export abstract class DeviceTrustServiceAbstraction {
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
abstract getDeviceKey(userId: UserId): Promise<DeviceKey | null>;
abstract setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void>;
abstract decryptUserKeyWithDeviceKey(
userId: UserId,
encryptedDevicePrivateKey: EncString,

View File

@@ -356,7 +356,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
}
}
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set device key.");
}

View File

@@ -5,5 +5,6 @@ import { KdfConfig } from "@bitwarden/key-management";
export interface NewSsoUserKeyConnectorConversion {
kdfConfig: KdfConfig;
keyConnectorUrl: string;
// SSO organization identifier, not UUID
organizationId: string;
}

View File

@@ -7,7 +7,8 @@ import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management";
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
@@ -16,21 +17,26 @@ import { Organization } from "../../../admin-console/models/domain/organization"
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
import { TokenService } from "../../../auth/services/token.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
import { Rc } from "../../../platform/misc/reference-counting/rc";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { OrganizationId, UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
import { KeyGenerationService } from "../../crypto";
import { EncString } from "../../crypto/models/enc-string";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion";
import {
USES_KEY_CONNECTOR,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
KeyConnectorService,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
USES_KEY_CONNECTOR,
} from "./key-connector.service";
describe("KeyConnectorService", () => {
@@ -43,6 +49,10 @@ describe("KeyConnectorService", () => {
const organizationService = mock<OrganizationService>();
const keyGenerationService = mock<KeyGenerationService>();
const logoutCallback = jest.fn();
const configService = mock<ConfigService>();
const registerSdkService = mock<RegisterSdkService>();
const securityStateService = mock<SecurityStateService>();
const accountCryptographicStateService = mock<AccountCryptographicStateService>();
let stateProvider: FakeStateProvider;
@@ -50,6 +60,7 @@ describe("KeyConnectorService", () => {
let masterPasswordService: FakeMasterPasswordService;
const mockUserId = Utils.newGuid() as UserId;
const mockSsoOrgIdentifier = "test-sso-org-id";
const mockOrgId = Utils.newGuid() as OrganizationId;
const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({
@@ -61,7 +72,7 @@ describe("KeyConnectorService", () => {
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: new PBKDF2KdfConfig(600_000),
keyConnectorUrl,
organizationId: mockOrgId,
organizationId: mockSsoOrgIdentifier,
};
beforeEach(() => {
@@ -82,6 +93,10 @@ describe("KeyConnectorService", () => {
keyGenerationService,
logoutCallback,
stateProvider,
configService,
registerSdkService,
securityStateService,
accountCryptographicStateService,
);
});
@@ -419,44 +434,52 @@ describe("KeyConnectorService", () => {
});
describe("convertNewSsoUserToKeyConnector", () => {
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
let mockMakeUserKeyResult: [UserKey, EncString];
describe("V2", () => {
const mockKeyConnectorKey = Utils.fromBufferToB64(new Uint8Array(64));
const mockUserKeyString = Utils.fromBufferToB64(new Uint8Array(64));
const mockPrivateKey = "mockPrivateKey789";
const mockKeyConnectorKeyWrappedUserKey = "2.mockWrappedUserKey";
const mockSigningKey = "mockSigningKey";
const mockSignedPublicKey = "mockSignedPublicKey";
const mockSecurityState = "mockSecurityState";
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
let mockSdkRef: any;
let mockSdk: any;
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
});
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
it.each([
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
[KdfType.Argon2id, 11, 65, 5],
])(
"sets up a new SSO user with key connector",
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
const expectedKdfConfig =
kdfType == KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(kdfIterations)
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: expectedKdfConfig,
keyConnectorUrl: keyConnectorUrl,
organizationId: mockOrgId,
mockSdkRef = {
value: {
auth: jest.fn().mockReturnValue({
registration: jest.fn().mockReturnValue({
post_keys_for_key_connector_registration: jest.fn().mockResolvedValue({
key_connector_key: mockKeyConnectorKey,
user_key: mockUserKeyString,
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
account_cryptographic_state: {
V2: {
private_key: mockPrivateKey,
signing_key: mockSigningKey,
signed_public_key: mockSignedPublicKey,
security_state: mockSecurityState,
},
},
}),
}),
}),
},
[Symbol.dispose]: jest.fn(),
};
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$.mockReturnValue(of(mockSdk));
});
it("should set up a new SSO user with key connector using V2", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
@@ -465,11 +488,253 @@ describe("KeyConnectorService", () => {
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId);
expect(mockSdk.take).toHaveBeenCalled();
expect(mockSdkRef.value.auth).toHaveBeenCalled();
const mockRegistration = mockSdkRef.value
.auth()
.registration().post_keys_for_key_connector_registration;
expect(mockRegistration).toHaveBeenCalledWith(
keyConnectorUrl,
mockSsoOrgIdentifier,
mockUserId,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
expect.any(EncString),
mockUserId,
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V2: {
private_key: mockPrivateKey,
signing_key: mockSigningKey,
signed_public_key: mockSignedPublicKey,
security_state: mockSecurityState,
},
},
mockUserId,
);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
mockSecurityState,
mockUserId,
);
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
expect(await firstValueFrom(conversionState.state$)).toBeNull();
});
it("should throw error when SDK is not available", async () => {
registerSdkService.registerClient$.mockReturnValue(
of(null as unknown as Rc<BitwardenClient>),
);
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("SDK not available");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
it("should throw error when account cryptographic state is not V2", async () => {
mockSdkRef.value
.auth()
.registration()
.post_keys_for_key_connector_registration.mockResolvedValue({
key_connector_key: mockKeyConnectorKey,
user_key: mockUserKeyString,
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
account_cryptographic_state: {
V1: {
private_key: mockPrivateKey,
},
},
});
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("Unexpected account cryptographic state version");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
it("should throw error when post_keys_for_key_connector_registration fails", async () => {
const sdkError = new Error("Key Connector registration failed");
mockSdkRef.value
.auth()
.registration()
.post_keys_for_key_connector_registration.mockRejectedValue(sdkError);
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("Key Connector registration failed");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
});
describe("V1", () => {
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
let mockMakeUserKeyResult: [UserKey, EncString];
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it.each([
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
[KdfType.Argon2id, 11, 65, 5],
])(
"sets up a new SSO user with key connector",
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
const expectedKdfConfig =
kdfType == KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(kdfIterations)
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: expectedKdfConfig,
keyConnectorUrl: keyConnectorUrl,
organizationId: mockSsoOrgIdentifier,
};
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expectedKdfConfig,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
keyConnectorUrl,
new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
new SetKeyConnectorKeyRequest(
mockMakeUserKeyResult[1].encryptedString!,
expectedKdfConfig,
mockSsoOrgIdentifier,
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
),
);
// Verify that conversion data is cleared from conversionState
expect(await firstValueFrom(conversionState.state$)).toBeNull();
},
);
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow(new Error("Key Connector error"));
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expectedKdfConfig,
new PBKDF2KdfConfig(600_000),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
@@ -488,76 +753,29 @@ describe("KeyConnectorService", () => {
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
new SetKeyConnectorKeyRequest(
mockMakeUserKeyResult[1].encryptedString!,
expectedKdfConfig,
mockOrgId,
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
),
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
});
it("should throw error when conversion data is null", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(null);
// Verify that conversion data is cleared from conversionState
expect(await firstValueFrom(conversionState.state$)).toBeNull();
},
);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow(new Error("Key Connector conversion not found"));
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
new Error("Key Connector error"),
);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
new PBKDF2KdfConfig(600_000),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
keyConnectorUrl,
new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)),
);
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
});
it("should throw error when conversion data is null", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(null);
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
new Error("Key Connector conversion not found"),
);
// Verify that no key generation or API calls were made
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
// Verify that no key generation or API calls were made
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,22 +9,36 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import {
Argon2KdfConfig,
KdfConfig,
KdfType,
KeyService,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "../../../admin-console/enums";
import { Organization } from "../../../admin-console/models/domain/organization";
import { TokenService } from "../../../auth/abstractions/token.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { KeysRequest } from "../../../models/request/keys.request";
import { LogService } from "../../../platform/abstractions/log.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "../../../platform/abstractions/sdk/sdk.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { MasterKey, UserKey } from "../../../types/key";
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
import { KeyGenerationService } from "../../crypto";
import { EncString } from "../../crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
import { SignedPublicKey, SignedSecurityState, WrappedSigningKey } from "../../types";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
@@ -75,6 +89,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
private stateProvider: StateProvider,
private configService: ConfigService,
private registerSdkService: RegisterSdkService,
private securityStateService: SecurityStateService,
private accountCryptographicStateService: AccountCryptographicStateService,
) {
this.convertAccountRequired$ = accountService.activeAccount$.pipe(
filter((account) => account != null),
@@ -152,8 +170,106 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
throw new Error("Key Connector conversion not found");
}
const { kdfConfig, keyConnectorUrl, organizationId } = conversion;
const { kdfConfig, keyConnectorUrl, organizationId: ssoOrganizationIdentifier } = conversion;
if (
await firstValueFrom(
this.configService.getFeatureFlag$(
FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration,
),
)
) {
await this.convertNewSsoUserToKeyConnectorV2(
userId,
keyConnectorUrl,
ssoOrganizationIdentifier,
);
} else {
await this.convertNewSsoUserToKeyConnectorV1(
userId,
kdfConfig,
keyConnectorUrl,
ssoOrganizationIdentifier,
);
}
await this.stateProvider
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
.update(() => null);
}
async convertNewSsoUserToKeyConnectorV2(
userId: UserId,
keyConnectorUrl: string,
ssoOrganizationIdentifier: string,
) {
const result = await firstValueFrom(
this.registerSdkService.registerClient$(userId).pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return ref.value
.auth()
.registration()
.post_keys_for_key_connector_registration(
keyConnectorUrl,
ssoOrganizationIdentifier,
asUuid(userId),
);
}),
),
);
if (!("V2" in result.account_cryptographic_state)) {
const version = Object.keys(result.account_cryptographic_state);
throw new Error(`Unexpected account cryptographic state version ${version}`);
}
await this.masterPasswordService.setMasterKey(
SymmetricCryptoKey.fromString(result.key_connector_key) as MasterKey,
userId,
);
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(result.user_key) as UserKey,
userId,
);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
new EncString(result.key_connector_key_wrapped_user_key),
userId,
);
await this.accountCryptographicStateService.setAccountCryptographicState(
result.account_cryptographic_state,
userId,
);
// Legacy states
await this.keyService.setPrivateKey(result.account_cryptographic_state.V2.private_key, userId);
await this.keyService.setUserSigningKey(
result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
userId,
);
await this.securityStateService.setAccountSecurityState(
result.account_cryptographic_state.V2.security_state as SignedSecurityState,
userId,
);
if (result.account_cryptographic_state.V2.signed_public_key != null) {
await this.keyService.setSignedPublicKey(
result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
userId,
);
}
}
async convertNewSsoUserToKeyConnectorV1(
userId: UserId,
kdfConfig: KdfConfig,
keyConnectorUrl: string,
ssoOrganizationIdentifier: string,
) {
const password = await this.keyGenerationService.createKey(512);
const masterKey = await this.keyService.makeMasterKey(
@@ -182,14 +298,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
const setPasswordRequest = new SetKeyConnectorKeyRequest(
userKey[1].encryptedString,
kdfConfig,
organizationId,
ssoOrganizationIdentifier,
keys,
);
await this.apiService.postSetKeyConnectorKey(setPasswordRequest);
await this.stateProvider
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
.update(() => null);
}
async setNewSsoUserKeyConnectorConversionData(

View File

@@ -5,7 +5,6 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
import { LogoutReason } from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { mockAccountInfoWith } from "../../../../spec";
import { AccountService } from "../../../auth/abstractions/account.service";
@@ -130,15 +129,6 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
[FeatureFlag.InactiveUserServerNotification]: true,
[FeatureFlag.PushNotificationsWhenLocked]: true,
};
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
});
policyService = mock<InternalPolicyService>();
defaultServerNotificationsService = new DefaultServerNotificationsService(

View File

@@ -71,48 +71,20 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
private readonly configService: ConfigService,
private readonly policyService: InternalPolicyService,
) {
this.notifications$ = this.configService
.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification)
.pipe(
distinctUntilChanged(),
switchMap((inactiveUserServerNotificationEnabled) => {
if (inactiveUserServerNotificationEnabled) {
return this.accountService.accounts$.pipe(
map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
const validUserIds = Object.entries(accounts)
.filter(
([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified,
)
.map(([userId, _]) => userId as UserId);
return new Set(validUserIds);
}),
trackedMerge((id: UserId) => {
return this.userNotifications$(id as UserId).pipe(
map(
(notification: NotificationResponse) => [notification, id as UserId] as const,
),
);
}),
);
}
return this.accountService.activeAccount$.pipe(
map((account) => account?.id),
distinctUntilChanged(),
switchMap((activeAccountId) => {
if (activeAccountId == null) {
// We don't emit server-notifications for inactive accounts currently
return EMPTY;
}
return this.userNotifications$(activeAccountId).pipe(
map((notification) => [notification, activeAccountId] as const),
);
}),
);
}),
share(), // Multiple subscribers should only create a single connection to the server
);
this.notifications$ = this.accountService.accounts$.pipe(
map((accounts: Record<UserId, AccountInfo>): Set<UserId> => {
const validUserIds = Object.entries(accounts)
.filter(([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified)
.map(([userId, _]) => userId as UserId);
return new Set(validUserIds);
}),
trackedMerge((id: UserId) => {
return this.userNotifications$(id as UserId).pipe(
map((notification: NotificationResponse) => [notification, id as UserId] as const),
);
}),
share(), // Multiple subscribers should only create a single connection to the server
);
}
/**
@@ -175,25 +147,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
}
private hasAccessToken$(userId: UserId) {
return this.configService.getFeatureFlag$(FeatureFlag.PushNotificationsWhenLocked).pipe(
return this.authService.authStatusFor$(userId).pipe(
map(
(authStatus) =>
authStatus === AuthenticationStatus.Locked ||
authStatus === AuthenticationStatus.Unlocked,
),
distinctUntilChanged(),
switchMap((featureFlagEnabled) => {
if (featureFlagEnabled) {
return this.authService.authStatusFor$(userId).pipe(
map(
(authStatus) =>
authStatus === AuthenticationStatus.Locked ||
authStatus === AuthenticationStatus.Unlocked,
),
distinctUntilChanged(),
);
} else {
return this.authService.authStatusFor$(userId).pipe(
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
distinctUntilChanged(),
);
}
}),
);
}
@@ -208,19 +168,13 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
return;
}
if (
await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification),
)
) {
const activeAccountId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const activeAccountId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const isActiveUser = activeAccountId === userId;
if (!isActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) {
return;
}
const notificationIsForActiveUser = activeAccountId === userId;
if (!notificationIsForActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) {
return;
}
switch (notification.type) {

View File

@@ -279,8 +279,8 @@ export class DefaultSyncService extends CoreSyncService {
await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
await this.accountService.setAccountCreationDate(response.id, new Date(response.creationDate));
await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices);
await this.accountService.setAccountCreationDate(response.id, response.creationDate);
await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally,

View File

@@ -207,9 +207,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* Update the local store of CipherData with the provided data. Values are upserted into the existing store.
*
* @param cipher The cipher data to upsert. Can be a single CipherData object or an array of CipherData objects.
* @param userId Optional user ID for whom the cipher data is being upserted.
* @returns A promise that resolves to a record of updated cipher store, keyed by their cipher ID. Returns all ciphers, not just those updated
*/
abstract upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>>;
abstract upsert(
cipher: CipherData | CipherData[],
userId?: UserId,
): Promise<Record<CipherId, CipherData>>;
abstract replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise<any>;
abstract clear(userId?: string): Promise<void>;
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;

View File

@@ -1196,12 +1196,15 @@ export class CipherService implements CipherServiceAbstraction {
await this.encryptedCiphersState(userId).update(() => ciphers);
}
async upsert(cipher: CipherData | CipherData[]): Promise<Record<CipherId, CipherData>> {
async upsert(
cipher: CipherData | CipherData[],
userId?: UserId,
): Promise<Record<CipherId, CipherData>> {
const ciphers = cipher instanceof CipherData ? [cipher] : cipher;
const res = await this.updateEncryptedCipherState((current) => {
ciphers.forEach((c) => (current[c.id as CipherId] = c));
return current;
});
}, userId);
// Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick
// Otherwise, subscribers to cipherViews$ can get stale data
await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -219,7 +219,7 @@ describe("DefaultCipherArchiveService", () => {
} as any,
}),
);
mockCipherService.replace.mockResolvedValue(undefined);
mockCipherService.upsert.mockResolvedValue(undefined);
});
it("should archive single cipher", async () => {
@@ -233,13 +233,13 @@ describe("DefaultCipherArchiveService", () => {
true,
);
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
expect(mockCipherService.replace).toHaveBeenCalledWith(
expect.objectContaining({
[cipherId]: expect.objectContaining({
expect(mockCipherService.upsert).toHaveBeenCalledWith(
[
expect.objectContaining({
archivedDate: "2024-01-15T10:30:00.000Z",
revisionDate: "2024-01-15T10:31:00.000Z",
}),
}),
],
userId,
);
});
@@ -282,7 +282,7 @@ describe("DefaultCipherArchiveService", () => {
} as any,
}),
);
mockCipherService.replace.mockResolvedValue(undefined);
mockCipherService.upsert.mockResolvedValue(undefined);
});
it("should unarchive single cipher", async () => {
@@ -296,12 +296,12 @@ describe("DefaultCipherArchiveService", () => {
true,
);
expect(mockCipherService.ciphers$).toHaveBeenCalledWith(userId);
expect(mockCipherService.replace).toHaveBeenCalledWith(
expect.objectContaining({
[cipherId]: expect.objectContaining({
expect(mockCipherService.upsert).toHaveBeenCalledWith(
[
expect.objectContaining({
revisionDate: "2024-01-15T10:31:00.000Z",
}),
}),
],
userId,
);
});

View File

@@ -95,7 +95,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.replace(currentCiphers, userId);
await this.cipherService.upsert(Object.values(currentCiphers), userId);
}
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
@@ -116,6 +116,6 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
localCipher.revisionDate = cipher.revisionDate;
}
await this.cipherService.replace(currentCiphers, userId);
await this.cipherService.upsert(Object.values(currentCiphers), userId);
}
}

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { skip, filter, map, combineLatestWith, tap } from "rxjs";
import { skip, filter, combineLatestWith, tap } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -19,8 +19,10 @@ export class RouterFocusManagerService {
*
* By default, we focus the `main` after an internal route navigation.
*
* Consumers can opt out of the passing the following to the `info` input:
* `<a [routerLink]="route()" [info]="{ focusMainAfterNav: false }"></a>`
* Consumers can opt out of the passing the following to the `state` input. Using `state`
* allows us to access the value between browser back/forward arrows.
* In template: `<a [routerLink]="route()" [state]="{ focusMainAfterNav: false }"></a>`
* In typescript: `this.router.navigate([], { state: { focusMainAfterNav: false }})`
*
* Or, consumers can use the autofocus directive on an applicable interactive element.
* The autofocus directive will take precedence over this route focus pipeline.
@@ -44,15 +46,12 @@ export class RouterFocusManagerService {
skip(1),
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)),
filter(([_navEvent, flagEnabled]) => flagEnabled),
map(() => {
const currentNavData = this.router.getCurrentNavigation()?.extras;
filter(() => {
const currentNavExtras = this.router.currentNavigation()?.extras;
const info = currentNavData?.info as { focusMainAfterNav?: boolean } | undefined;
const focusMainAfterNav: boolean | undefined = currentNavExtras?.state?.focusMainAfterNav;
return info;
}),
filter((currentNavInfo) => {
return currentNavInfo === undefined ? true : currentNavInfo?.focusMainAfterNav !== false;
return focusMainAfterNav !== false;
}),
tap(() => {
const mainEl = document.querySelector<HTMLElement>("main");

View File

@@ -6,13 +6,14 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an
import { getAllByRole, userEvent } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
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 { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { DialogModule } from "./dialog.module";
import { DialogService } from "./dialog.service";
@@ -161,6 +162,10 @@ export default {
});
},
},
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],

View File

@@ -1,7 +1,8 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { ButtonModule } from "../button";
import { CalloutModule } from "../callout";
@@ -9,7 +10,7 @@ import { LayoutComponent } from "../layout";
import { mockLayoutI18n } from "../layout/mocks";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { TypographyModule } from "../typography";
import { I18nMockService } from "../utils";
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { DrawerBodyComponent } from "./drawer-body.component";
import { DrawerHeaderComponent } from "./drawer-header.component";
@@ -47,6 +48,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
} as Meta<DrawerComponent>;

View File

@@ -1,16 +1,23 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
import {
Meta,
StoryObj,
applicationConfig,
componentWrapperDecorator,
moduleMetadata,
} from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { AvatarModule } from "../avatar";
import { BadgeModule } from "../badge";
import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { TypographyModule } from "../typography";
import { I18nMockService } from "../utils/i18n-mock.service";
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { ItemActionComponent } from "./item-action.component";
import { ItemContentComponent } from "./item-content.component";
@@ -50,6 +57,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
componentWrapperDecorator((story) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
],
parameters: {

View File

@@ -1,13 +1,15 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { userEvent } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { CalloutModule } from "../callout";
import { NavigationModule } from "../navigation";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { LayoutComponent } from "./layout.component";
import { mockLayoutI18n } from "./mocks";
@@ -28,6 +30,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
parameters: {
chromatic: { viewports: [640, 1280] },

View File

@@ -5,4 +5,5 @@ export const mockLayoutI18n = {
submenu: "submenu",
toggleCollapse: "toggle collapse",
loading: "Loading",
resizeSideNavigation: "Resize side navigation",
};

View File

@@ -3,11 +3,13 @@ import { RouterModule } from "@angular/router";
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { LayoutComponent } from "../layout";
import { SharedModule } from "../shared/shared.module";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { NavGroupComponent } from "./nav-group.component";
import { NavigationModule } from "./navigation.module";
@@ -42,6 +44,7 @@ export default {
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
loading: "Loading",
resizeSideNavigation: "Resize side navigation",
});
},
},
@@ -58,6 +61,10 @@ export default {
{ useHash: true },
),
),
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],

View File

@@ -1,12 +1,14 @@
import { RouterTestingModule } from "@angular/router/testing";
import { StoryObj, Meta, moduleMetadata } from "@storybook/angular";
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { NavItemComponent } from "./nav-item.component";
import { NavigationModule } from "./navigation.module";
@@ -31,11 +33,20 @@ export default {
toggleSideNavigation: "Toggle side navigation",
skipToContent: "Skip to content",
loading: "Loading",
resizeSideNavigation: "Resize side navigation",
});
},
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
parameters: {
design: {

View File

@@ -5,47 +5,64 @@
};
as data
) {
<nav
id="bit-side-nav"
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[ngClass]="{ 'tw-w-60': data.open }"
[ngStyle]="
variant() === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)',
'--color-hover-contrast': 'var(--color-hover-default)',
}
"
[cdkTrapFocus]="data.isOverlay"
[attr.role]="data.isOverlay ? 'dialog' : null"
[attr.aria-modal]="data.isOverlay ? 'true' : null"
(keydown)="handleKeyDown($event)"
>
<ng-content></ng-content>
<!-- 53rem = ~850px -->
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
<div
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
<div class="tw-relative tw-h-full">
<nav
id="bit-side-nav"
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
[ngStyle]="
variant() === 'secondary' && {
'--color-text-alt2': 'var(--color-text-main)',
'--color-background-alt3': 'var(--color-secondary-100)',
'--color-background-alt4': 'var(--color-secondary-300)',
'--color-hover-contrast': 'var(--color-hover-default)',
}
"
[cdkTrapFocus]="data.isOverlay"
[attr.role]="data.isOverlay ? 'dialog' : null"
[attr.aria-modal]="data.isOverlay ? 'true' : null"
(keydown)="handleKeyDown($event)"
>
<bit-nav-divider></bit-nav-divider>
@if (data.open) {
<ng-content select="[slot=footer]"></ng-content>
}
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
<button
#toggleButton
type="button"
class="tw-mx-auto tw-block tw-max-w-fit"
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
buttonType="nav-contrast"
size="small"
(click)="sideNavService.toggle()"
[label]="'toggleSideNavigation' | i18n"
[attr.aria-expanded]="data.open"
aria-controls="bit-side-nav"
></button>
<ng-content></ng-content>
<!-- 53rem = ~850px -->
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
<div
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
>
<bit-nav-divider></bit-nav-divider>
@if (data.open) {
<ng-content select="[slot=footer]"></ng-content>
}
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
<button
#toggleButton
type="button"
class="tw-mx-auto tw-block tw-max-w-fit"
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
buttonType="nav-contrast"
size="small"
(click)="sideNavService.toggle()"
[label]="'toggleSideNavigation' | i18n"
[attr.aria-expanded]="data.open"
aria-controls="bit-side-nav"
></button>
</div>
</div>
</div>
</nav>
</nav>
<div
cdkDrag
(cdkDragMoved)="onDragMoved($event)"
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-200 hover:tw-ease-in-out hover:tw-delay-500 hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
[class.tw-hidden]="!data.open"
tabindex="0"
(keydown)="onKeydown($event)"
role="separator"
[attr.aria-valuenow]="sideNavService.width$ | async"
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
aria-orientation="vertical"
aria-controls="bit-side-nav"
[attr.aria-label]="'resizeSideNavigation' | i18n"
></div>
</div>
}

View File

@@ -1,4 +1,5 @@
import { CdkTrapFocus } from "@angular/cdk/a11y";
import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, inject, input, viewChild } from "@angular/core";
@@ -16,16 +17,26 @@ export type SideNavVariant = "primary" | "secondary";
@Component({
selector: "bit-side-nav",
templateUrl: "side-nav.component.html",
imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe],
imports: [
CommonModule,
CdkTrapFocus,
NavDividerComponent,
BitIconButtonComponent,
I18nPipe,
DragDropModule,
],
host: {
class: "tw-block tw-h-full",
},
})
export class SideNavComponent {
protected sideNavService = inject(SideNavService);
readonly variant = input<SideNavVariant>("primary");
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
protected sideNavService = inject(SideNavService);
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
protected handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@@ -36,4 +47,21 @@ export class SideNavComponent {
return true;
};
protected onDragMoved(event: CdkDragMove) {
const rectX = this.elementRef.nativeElement.getBoundingClientRect().x;
const eventXPointer = event.pointerPosition.x;
this.sideNavService.setWidthFromDrag(eventXPointer, rectX);
// Fix for CDK applying a transform that can cause visual drifting
const element = event.source.element.nativeElement;
element.style.transform = "none";
}
protected onKeydown(event: KeyboardEvent) {
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
this.sideNavService.setWidthFromKeys(event.key);
}
}
}

View File

@@ -1,15 +1,37 @@
import { Injectable } from "@angular/core";
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, combineLatest, fromEvent, map, startWith } from "rxjs";
import {
BehaviorSubject,
Observable,
combineLatest,
fromEvent,
map,
startWith,
debounceTime,
first,
} from "rxjs";
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
import { BREAKPOINTS, isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
type CollapsePreference = "open" | "closed" | null;
const BIT_SIDE_NAV_WIDTH_KEY_DEF = new KeyDefinition<number>(BIT_SIDE_NAV_DISK, "side-nav-width", {
deserializer: (s) => s,
});
@Injectable({
providedIn: "root",
})
export class SideNavService {
// Units in rem
readonly DEFAULT_OPEN_WIDTH = 18;
readonly MIN_OPEN_WIDTH = 15;
readonly MAX_OPEN_WIDTH = 24;
private rootFontSizePx: number;
private _open$ = new BehaviorSubject<boolean>(isAtOrLargerThanBreakpoint("md"));
open$ = this._open$.asObservable();
@@ -21,7 +43,30 @@ export class SideNavService {
map(([open, isLargeScreen]) => open && !isLargeScreen),
);
/**
* Local component state width
*
* This observable has immediate pixel-perfect updates for the sidebar display width to use
*/
private readonly _width$ = new BehaviorSubject<number>(this.DEFAULT_OPEN_WIDTH);
readonly width$ = this._width$.asObservable();
/**
* State provider width
*
* This observable is used to initialize the component state and will be periodically synced
* to the local _width$ state to avoid excessive writes
*/
private readonly widthState = inject(GlobalStateProvider).get(BIT_SIDE_NAV_WIDTH_KEY_DEF);
readonly widthState$ = this.widthState.state$.pipe(
map((width) => width ?? this.DEFAULT_OPEN_WIDTH),
);
constructor() {
// Get computed root font size to support user-defined a11y font increases
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
// Handle open/close state
combineLatest([this.isLargeScreen$, this.userCollapsePreference$])
.pipe(takeUntilDestroyed())
.subscribe(([isLargeScreen, userCollapsePreference]) => {
@@ -32,6 +77,16 @@ export class SideNavService {
this.setOpen();
}
});
// Initialize the resizable width from state provider
this.widthState$.pipe(first()).subscribe((width: number) => {
this._width$.next(width);
});
// Periodically sync to state provider when component state changes
this.width$.pipe(debounceTime(200), takeUntilDestroyed()).subscribe((width) => {
void this.widthState.update(() => width);
});
}
get open() {
@@ -46,6 +101,9 @@ export class SideNavService {
this._open$.next(false);
}
/**
* Toggle the open/close state of the side nav
*/
toggle() {
const curr = this._open$.getValue();
// Store user's preference based on what state they're toggling TO
@@ -57,8 +115,51 @@ export class SideNavService {
this.setOpen();
}
}
/**
* Set new side nav width from drag event coordinates
*
* @param eventXCoordinate x coordinate of the pointer's bounding client rect
* @param dragElementXCoordinate x coordinate of the drag element's bounding client rect
*/
setWidthFromDrag(eventXPointer: number, dragElementXCoordinate: number) {
const newWidthInPixels = eventXPointer - dragElementXCoordinate;
const newWidthInRem = newWidthInPixels / this.rootFontSizePx;
this._setWidthWithinMinMax(newWidthInRem);
}
/**
* Set new side nav width from arrow key events
*
* @param key event key, must be either ArrowRight or ArrowLeft
*/
setWidthFromKeys(key: "ArrowRight" | "ArrowLeft") {
const currentWidth = this._width$.getValue();
const delta = key === "ArrowLeft" ? -1 : 1;
const newWidth = currentWidth + delta;
this._setWidthWithinMinMax(newWidth);
}
/**
* Calculate and set the new width, not going out of the min/max bounds
* @param newWidth desired new width: number
*/
private _setWidthWithinMinMax(newWidth: number) {
const width = Math.min(Math.max(newWidth, this.MIN_OPEN_WIDTH), this.MAX_OPEN_WIDTH);
this._width$.next(width);
}
}
/**
* Helper function for subscribing to media query events
* @param query media query to validate against
* @returns Observable<boolean>
*/
export const media = (query: string): Observable<boolean> => {
const mediaQuery = window.matchMedia(query);
return fromEvent<MediaQueryList>(mediaQuery, "change").pipe(

View File

@@ -2,127 +2,772 @@ import { Meta } from "@storybook/addon-docs/blocks";
<Meta title="Documentation/Colors" />
export const Row = (name) => (
<tr class="tw-h-16">
<td class="!tw-border-none">{name}</td>
<td class={"tw-bg-" + name + " !tw-border-secondary-300"}></td>
</tr>
);
# Color System
export const Table = (args) => (
<table class={"border tw-table-auto !tw-text-main " + args.class}>
<thead>
<tr>
<th class="tw-w-40">General usage</th>
<th class="tw-w-20"></th>
</tr>
</thead>
<tbody>
{Row("background")}
{Row("background-alt")}
{Row("background-alt2")}
{Row("background-alt3")}
{Row("background-alt4")}
</tbody>
<tbody>
{Row("primary-100")}
{Row("primary-300")}
{Row("primary-600")}
{Row("primary-700")}
</tbody>
<tbody>
{Row("secondary-100")}
{Row("secondary-300")}
{Row("secondary-500")}
{Row("secondary-600")}
{Row("secondary-700")}
</tbody>
<tbody>
{Row("success-100")}
{Row("success-600")}
{Row("success-700")}
</tbody>
<tbody>
{Row("danger-100")}
{Row("danger-600")}
{Row("danger-700")}
</tbody>
<tbody>
{Row("warning-100")}
{Row("warning-600")}
{Row("warning-700")}
</tbody>
<tbody>
{Row("info-100")}
{Row("info-600")}
{Row("info-700")}
</tbody>
<tbody>
{Row("notification-100")}
{Row("notification-600")}
</tbody>
<tbody>
{Row("illustration-outline")}
{Row("illustration-bg-primary")}
{Row("illustration-bg-secondary")}
{Row("illustration-bg-tertiary")}
{Row("illustration-tertiary")}
{Row("illustration-logo")}
</tbody>
Bitwarden uses a three-tier color token architecture:
<thead>
<tr>
<th>Text</th>
<th class="tw-w-20"></th>
</tr>
</thead>
<tbody>
{Row("text-main")}
{Row("text-muted")}
{Row("text-contrast")}
{Row("text-alt2")}
{Row("text-code")}
</tbody>
- **Primitive Colors** - Raw color values from the Figma design system
- **Semantic Tokens** - Meaningful names that reference primitives
- **Tailwind Utilities** - CSS classes for components
</table>
);
## Color Token Structure
<style>
{`
table {
border-spacing: 0.5rem;
border-collapse: separate !important;
}
### Primitive Colors (Hex format)
tr {
background: none !important;
border: none !important;
}
Location: `libs/components/src/tw-theme.css`
td, th {
color: inherit !important;
}
- 10 color families: `brand`, `gray`, `red`, `orange`, `yellow`, `green`, `pink`, `coral`, `teal`,
`purple`
- 11 shades each: `050`, `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900`, `950`
- Format: `--color-{family}-{shade}` (e.g., `--color-brand-600`)
th {
border: none !important;
}
`}
</style>
### Semantic Foreground Tokens
# Colors
- **Neutral**: `fg-white`, `fg-dark`, `fg-contrast`, `fg-heading`, `fg-body`, `fg-body-subtle`,
`fg-disabled`
- **Brand**: `fg-brand-soft`, `fg-brand`, `fg-brand-strong`
- **Status**: `fg-success`, `fg-success-strong`, `fg-danger`, `fg-danger-strong`, `fg-warning`,
`fg-warning-strong`, `fg-sensitive`
- **Accent**: `fg-accent-primary`, `fg-accent-secondary`, `fg-accent-tertiary` (with `-soft` and
`-strong` variants)
- Format: `--color-fg-{name}`
Tailwind traditionally has a very large color palette. Bitwarden has their own more limited color
palette instead.
### Semantic Background Tokens
This has a couple of advantages:
- **Neutral**: `bg-white`, `bg-dark`, `bg-contrast`, `bg-contrast-strong`, `bg-primary`,
`bg-secondary`, `bg-tertiary`, `bg-quaternary`, `bg-gray`, `bg-disabled`
- **Brand**: `bg-brand-softer`, `bg-brand-soft`, `bg-brand-medium`, `bg-brand`, `bg-brand-strong`
- **Status**: `bg-success-soft`, `bg-success-medium`, `bg-success`, `bg-success-strong`,
`bg-danger-soft`, `bg-danger-medium`, `bg-danger`, `bg-danger-strong`, `bg-warning-soft`,
`bg-warning-medium`, `bg-warning`, `bg-warning-strong`
- **Accent**: `bg-accent-primary-soft`, `bg-accent-primary-medium`, `bg-accent-primary`,
`bg-accent-secondary-soft`, `bg-accent-secondary-medium`, `bg-accent-secondary`,
`bg-accent-tertiary-soft`, `bg-accent-tertiary-medium`, `bg-accent-tertiary`
- **Special**: `bg-hover`, `bg-overlay`
- Format: `--color-bg-{name}`
- Promotes consistency across the application.
- Easier to maintain and make adjustments.
- Allows us to support more than two themes light & dark, should it be needed.
### Semantic Border Tokens
Below are all the permited colors. Please consult design before considering adding a new color.
- **Neutral**: `border-muted`, `border-light`, `border-base`, `border-strong`, `border-buffer`
- **Brand**: `border-brand-soft`, `border-brand`, `border-brand-strong`
- **Status**: `border-success-soft`, `border-success`, `border-success-strong`,
`border-danger-soft`, `border-danger`, `border-danger-strong`, `border-warning-soft`,
`border-warning`, `border-warning-strong`
- **Accent**: `border-accent-primary-soft`, `border-accent-primary`, `border-accent-secondary-soft`,
`border-accent-secondary`, `border-accent-tertiary-soft`, `border-accent-tertiary`
- **Focus**: `border-focus`
- Format: `--color-border-{name}`
<div class="tw-flex tw-space-x-4">
<Table />
<Table class="theme_dark tw-bg-background" />
## Semantic Color Tokens
> **Note:** Due to Tailwind's utility naming and our semantic token structure, class names will
> appear repetitive (e.g., `tw-bg-bg-primary`). This repetition is intentional:
>
> - `tw-` = Tailwind prefix
> - `bg-` = Tailwind utility type (background)
> - `bg-primary` = Our semantic token name
### Background Colors
Use `tw-bg-bg-*` for background colors. These tokens automatically adapt to dark mode.
export const Swatch = ({ name }) => {
const swatchClass = `tw-h-10 tw-w-10 tw-shrink-0 tw-rounded-lg tw-border tw-border-border-base tw-bg-${name}`;
return <div className={swatchClass} />;
};
export const BackgroundCard = ({ name, primitiveColor }) => {
const bgClass = `tw-flex tw-items-center tw-gap-3 tw-rounded-xl tw-p-4 tw-border tw-border-border-base tw-bg-bg-primary`;
const swatchClass = `tw-h-10 tw-w-10 tw-shrink-0 tw-rounded-lg tw-border tw-border-base tw-bg-bg-${name}`;
return (
<div className={bgClass}>
<div className="tw-flex-1 tw-min-w-0">
<div className="tw-font-mono tw-text-sm tw-font-semibold tw-text-fg-heading">bg-{name}</div>
<div className="tw-text-xs tw-text-fg-body-subtle tw-mt-0.5">({primitiveColor})</div>
</div>
<Swatch name={`bg-${name}`} />
</div>
);
};
<div class="sb-unstyled tw-grid tw-grid-cols-2 tw-gap-8 tw-my-6">
<div class="tw-bg-bg-primary tw-p-6 tw-rounded-lg tw-border tw-border-border-base">
<h3 class="sb-unstyled sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Light mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Neutral</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="white" primitiveColor="white" />
<BackgroundCard name="dark" primitiveColor="gray-800" />
<BackgroundCard name="contrast" primitiveColor="gray-800" />
<BackgroundCard name="contrast-strong" primitiveColor="gray-950" />
<BackgroundCard name="primary" primitiveColor="white" />
<BackgroundCard name="secondary" primitiveColor="gray-050" />
<BackgroundCard name="tertiary" primitiveColor="gray-050" />
<BackgroundCard name="quaternary" primitiveColor="gray-200" />
<BackgroundCard name="gray" primitiveColor="gray-300" />
<BackgroundCard name="disabled" primitiveColor="gray-100" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Brand</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="brand-softer" primitiveColor="brand-050" />
<BackgroundCard name="brand-soft" primitiveColor="brand-100" />
<BackgroundCard name="brand-medium" primitiveColor="brand-200" />
<BackgroundCard name="brand" primitiveColor="brand-700" />
<BackgroundCard name="brand-strong" primitiveColor="brand-800" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Status</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="success-soft" primitiveColor="green-050" />
<BackgroundCard name="success-medium" primitiveColor="green-100" />
<BackgroundCard name="success" primitiveColor="green-600" />
<BackgroundCard name="success-strong" primitiveColor="green-700" />
<BackgroundCard name="danger-soft" primitiveColor="red-050" />
<BackgroundCard name="danger-medium" primitiveColor="red-100" />
<BackgroundCard name="danger" primitiveColor="red-600" />
<BackgroundCard name="danger-strong" primitiveColor="red-700" />
<BackgroundCard name="warning-soft" primitiveColor="orange-050" />
<BackgroundCard name="warning-medium" primitiveColor="orange-100" />
<BackgroundCard name="warning" primitiveColor="orange-600" />
<BackgroundCard name="warning-strong" primitiveColor="orange-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Accent</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="accent-primary-soft" primitiveColor="teal-050" />
<BackgroundCard name="accent-primary-medium" primitiveColor="teal-100" />
<BackgroundCard name="accent-primary" primitiveColor="teal-400" />
<BackgroundCard name="accent-secondary-soft" primitiveColor="coral-050" />
<BackgroundCard name="accent-secondary-medium" primitiveColor="coral-100" />
<BackgroundCard name="accent-secondary" primitiveColor="coral-400" />
<BackgroundCard name="accent-tertiary-soft" primitiveColor="purple-050" />
<BackgroundCard name="accent-tertiary-medium" primitiveColor="purple-100" />
<BackgroundCard name="accent-tertiary" primitiveColor="purple-600" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Hover</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="hover" primitiveColor="rgba" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Overlay</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="overlay" primitiveColor="rgba" />
</div>
</div>
</div>
<div class="theme_dark tw-bg-bg-primary tw-p-6 tw-rounded-lg">
<h3 class="sb-unstyled sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Dark mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Neutral</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="white" primitiveColor="white" />
<BackgroundCard name="dark" primitiveColor="gray-800" />
<BackgroundCard name="contrast" primitiveColor="gray-050" />
<BackgroundCard name="contrast-strong" primitiveColor="gray-950" />
<BackgroundCard name="primary" primitiveColor="gray-900" />
<BackgroundCard name="secondary" primitiveColor="gray-800" />
<BackgroundCard name="tertiary" primitiveColor="gray-950" />
<BackgroundCard name="quaternary" primitiveColor="gray-950" />
<BackgroundCard name="gray" primitiveColor="gray-600" />
<BackgroundCard name="disabled" primitiveColor="gray-950" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Brand</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="brand-softer" primitiveColor="brand-950" />
<BackgroundCard name="brand-soft" primitiveColor="brand-900" />
<BackgroundCard name="brand-medium" primitiveColor="brand-800" />
<BackgroundCard name="brand" primitiveColor="brand-400" />
<BackgroundCard name="brand-strong" primitiveColor="brand-300" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Status</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="success-soft" primitiveColor="green-950" />
<BackgroundCard name="success-medium" primitiveColor="green-900" />
<BackgroundCard name="success" primitiveColor="green-400" />
<BackgroundCard name="success-strong" primitiveColor="green-300" />
<BackgroundCard name="danger-soft" primitiveColor="red-950" />
<BackgroundCard name="danger-medium" primitiveColor="red-900" />
<BackgroundCard name="danger" primitiveColor="red-400" />
<BackgroundCard name="danger-strong" primitiveColor="red-300" />
<BackgroundCard name="warning-soft" primitiveColor="orange-950" />
<BackgroundCard name="warning-medium" primitiveColor="orange-900" />
<BackgroundCard name="warning" primitiveColor="orange-400" />
<BackgroundCard name="warning-strong" primitiveColor="orange-300" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Accent</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="accent-primary-soft" primitiveColor="teal-950" />
<BackgroundCard name="accent-primary-medium" primitiveColor="teal-900" />
<BackgroundCard name="accent-primary" primitiveColor="teal-400" />
<BackgroundCard name="accent-secondary-soft" primitiveColor="coral-950" />
<BackgroundCard name="accent-secondary-medium" primitiveColor="coral-900" />
<BackgroundCard name="accent-secondary" primitiveColor="coral-400" />
<BackgroundCard name="accent-tertiary-soft" primitiveColor="purple-950" />
<BackgroundCard name="accent-tertiary-medium" primitiveColor="purple-900" />
<BackgroundCard name="accent-tertiary" primitiveColor="purple-600" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Hover</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="hover" primitiveColor="rgba" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Overlay</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BackgroundCard name="overlay" primitiveColor="rgba" />
</div>
</div>
</div>
</div>
---
### Foreground Colors
Use `tw-text-fg-*` for text colors. These tokens automatically adapt to dark mode.
export const ForegroundCard = ({ name, primitiveColor }) => {
const textClass = `tw-text-fg-${name} tw-text-2xl tw-font-bold tw-shrink-0`;
return (
<div className="tw-flex tw-items-center tw-gap-3 tw-rounded-xl tw-p-4 tw-border tw-border-border-base tw-bg-bg-primary">
<div className="tw-flex-1 tw-min-w-0">
<div className="tw-font-mono tw-text-sm tw-font-semibold tw-text-fg-heading">fg-{name}</div>
<div className="tw-text-xs tw-text-fg-body-subtle tw-mt-0.5">({primitiveColor})</div>
</div>
<Swatch name={`fg-${name}`} />
</div>
);
};
<div class="sb-unstyled tw-grid tw-grid-cols-2 tw-gap-8 tw-my-6">
<div class="tw-bg-bg-primary tw-p-6 tw-rounded-lg tw-border tw-border-border-base">
<h3 class="sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Light mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Neutral</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="white" primitiveColor="#ffffff" />
<ForegroundCard name="dark" primitiveColor="gray-900" />
<ForegroundCard name="contrast" primitiveColor="white" />
<ForegroundCard name="heading" primitiveColor="gray-900" />
<ForegroundCard name="body" primitiveColor="gray-600" />
<ForegroundCard name="body-subtle" primitiveColor="gray-500" />
<ForegroundCard name="disabled" primitiveColor="gray-400" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Brand</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="brand-soft" primitiveColor="brand-200" />
<ForegroundCard name="brand" primitiveColor="brand-700" />
<ForegroundCard name="brand-strong" primitiveColor="brand-900" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Status</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="success" primitiveColor="green-700" />
<ForegroundCard name="success-strong" primitiveColor="green-900" />
<ForegroundCard name="danger" primitiveColor="red-700" />
<ForegroundCard name="danger-strong" primitiveColor="red-900" />
<ForegroundCard name="warning" primitiveColor="orange-600" />
<ForegroundCard name="warning-strong" primitiveColor="orange-900" />
<ForegroundCard name="sensitive" primitiveColor="pink-600" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Accent</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="accent-primary-soft" primitiveColor="teal-200" />
<ForegroundCard name="accent-primary" primitiveColor="teal-400" />
<ForegroundCard name="accent-primary-strong" primitiveColor="teal-800" />
<ForegroundCard name="accent-secondary-soft" primitiveColor="coral-200" />
<ForegroundCard name="accent-secondary" primitiveColor="coral-400" />
<ForegroundCard name="accent-secondary-strong" primitiveColor="coral-900" />
<ForegroundCard name="accent-tertiary-soft" primitiveColor="purple-200" />
<ForegroundCard name="accent-tertiary" primitiveColor="purple-700" />
<ForegroundCard name="accent-tertiary-strong" primitiveColor="purple-900" />
</div>
</div>
</div>
<div class="theme_dark tw-bg-bg-primary tw-p-6 tw-rounded-lg">
<h3 class="sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Dark mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Neutral</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="white" primitiveColor="#ffffff" />
<ForegroundCard name="dark" primitiveColor="gray-900" />
<ForegroundCard name="contrast" primitiveColor="gray-900" />
<ForegroundCard name="heading" primitiveColor="gray-050" />
<ForegroundCard name="body" primitiveColor="gray-200" />
<ForegroundCard name="body-subtle" primitiveColor="gray-400" />
<ForegroundCard name="disabled" primitiveColor="gray-600" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Brand</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="brand-soft" primitiveColor="brand-500" />
<ForegroundCard name="brand" primitiveColor="brand-400" />
<ForegroundCard name="brand-strong" primitiveColor="brand-200" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Status</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="success" primitiveColor="green-400" />
<ForegroundCard name="success-strong" primitiveColor="green-100" />
<ForegroundCard name="danger" primitiveColor="red-400" />
<ForegroundCard name="danger-strong" primitiveColor="red-100" />
<ForegroundCard name="warning" primitiveColor="orange-400" />
<ForegroundCard name="warning-strong" primitiveColor="orange-100" />
<ForegroundCard name="sensitive" primitiveColor="pink-300" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Accent</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<ForegroundCard name="accent-primary-soft" primitiveColor="teal-400" />
<ForegroundCard name="accent-primary" primitiveColor="teal-300" />
<ForegroundCard name="accent-primary-strong" primitiveColor="teal-100" />
<ForegroundCard name="accent-secondary-soft" primitiveColor="coral-500" />
<ForegroundCard name="accent-secondary" primitiveColor="coral-400" />
<ForegroundCard name="accent-secondary-strong" primitiveColor="coral-100" />
<ForegroundCard name="accent-tertiary-soft" primitiveColor="purple-500" />
<ForegroundCard name="accent-tertiary" primitiveColor="purple-400" />
<ForegroundCard name="accent-tertiary-strong" primitiveColor="purple-100" />
</div>
</div>
</div>
</div>
---
### Border Colors
Use `tw-border-border-*` for border colors. These tokens automatically adapt to dark mode.
export const BorderCard = ({ name, primitiveColor }) => {
return (
<div className="tw-flex tw-items-center tw-gap-3 tw-rounded-xl tw-p-4 tw-border tw-border-border-base tw-bg-bg-primary">
<div className="tw-flex-1 tw-min-w-0">
<div className="tw-font-mono tw-text-sm tw-font-semibold tw-text-fg-heading">
border-{name}
</div>
<div className="tw-text-xs tw-text-fg-body-subtle tw-mt-0.5">({primitiveColor})</div>
</div>
<Swatch name={`border-${name}`} />
</div>
);
};
<div class="sb-unstyled tw-grid tw-grid-cols-2 tw-gap-8 tw-my-6">
<div class="tw-bg-bg-primary tw-p-6 tw-rounded-lg tw-border tw-border-border-base">
<h3 class="sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Light mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Neutral</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="muted" primitiveColor="gray-100" />
<BorderCard name="light" primitiveColor="gray-200" />
<BorderCard name="base" primitiveColor="gray-200" />
<BorderCard name="strong" primitiveColor="gray-300" />
<BorderCard name="buffer" primitiveColor="gray-100" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Brand</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="brand-soft" primitiveColor="brand-200" />
<BorderCard name="brand" primitiveColor="brand-700" />
<BorderCard name="brand-strong" primitiveColor="brand-900" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Status</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="success-soft" primitiveColor="green-200" />
<BorderCard name="success" primitiveColor="green-700" />
<BorderCard name="success-strong" primitiveColor="green-900" />
<BorderCard name="danger-soft" primitiveColor="red-200" />
<BorderCard name="danger" primitiveColor="red-700" />
<BorderCard name="danger-strong" primitiveColor="red-900" />
<BorderCard name="warning-soft" primitiveColor="orange-200" />
<BorderCard name="warning" primitiveColor="orange-600" />
<BorderCard name="warning-strong" primitiveColor="orange-900" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Accent</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="accent-primary-soft" primitiveColor="teal-200" />
<BorderCard name="accent-primary" primitiveColor="teal-600" />
<BorderCard name="accent-secondary-soft" primitiveColor="coral-200" />
<BorderCard name="accent-secondary" primitiveColor="coral-600" />
<BorderCard name="accent-tertiary-soft" primitiveColor="purple-200" />
<BorderCard name="accent-tertiary" primitiveColor="purple-600" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Focus</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="focus" primitiveColor="#000000" />
</div>
</div>
</div>
<div class="theme_dark tw-bg-bg-primary tw-p-6 tw-rounded-lg">
<h3 class="sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Dark mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Neutral</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="muted" primitiveColor="gray-800" />
<BorderCard name="light" primitiveColor="gray-700" />
<BorderCard name="base" primitiveColor="gray-700" />
<BorderCard name="strong" primitiveColor="gray-600" />
<BorderCard name="buffer" primitiveColor="gray-900" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Brand</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="brand-soft" primitiveColor="brand-800" />
<BorderCard name="brand" primitiveColor="brand-700" />
<BorderCard name="brand-strong" primitiveColor="brand-600" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Status</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="success-soft" primitiveColor="green-800" />
<BorderCard name="success" primitiveColor="green-400" />
<BorderCard name="success-strong" primitiveColor="green-200" />
<BorderCard name="danger-soft" primitiveColor="red-800" />
<BorderCard name="danger" primitiveColor="red-400" />
<BorderCard name="danger-strong" primitiveColor="red-200" />
<BorderCard name="warning-soft" primitiveColor="orange-800" />
<BorderCard name="warning" primitiveColor="orange-400" />
<BorderCard name="warning-strong" primitiveColor="orange-200" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Accent</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="accent-primary-soft" primitiveColor="teal-800" />
<BorderCard name="accent-primary" primitiveColor="teal-600" />
<BorderCard name="accent-secondary-soft" primitiveColor="coral-800" />
<BorderCard name="accent-secondary" primitiveColor="coral-500" />
<BorderCard name="accent-tertiary-soft" primitiveColor="purple-800" />
<BorderCard name="accent-tertiary" primitiveColor="purple-500" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Focus</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<BorderCard name="focus" primitiveColor="#ffffff" />
</div>
</div>
</div>
</div>
---
## Usage Guidelines
### ✅ DO - Use semantic tokens via Tailwind
```html
<!-- Text colors -->
<h1 class="tw-text-fg-heading">Heading text</h1>
<p class="tw-text-fg-body">Body text</p>
<button class="tw-text-fg-brand">Brand action</button>
<span class="tw-text-fg-danger">Error message</span>
<!-- Background colors -->
<div class="tw-bg-bg-primary">Primary background</div>
<div class="tw-bg-bg-secondary">Secondary background</div>
<button class="tw-bg-bg-brand tw-text-fg-white">Brand button</button>
<div class="tw-bg-bg-danger-soft tw-text-fg-danger">Danger alert</div>
<!-- Border colors -->
<div class="tw-border tw-border-border-base">Base border</div>
<input class="tw-border tw-border-border-light focus:tw-border-border-focus" />
<div class="tw-border-2 tw-border-border-brand">Brand border</div>
<button class="tw-border tw-border-border-danger">Danger border</button>
<!-- Combined examples -->
<div
class="tw-bg-bg-success-soft tw-text-fg-success tw-border tw-border-border-success-soft tw-rounded tw-p-4"
>
Success alert with matching colors
</div>
<!-- Hover states -->
<div class="hover:tw-bg-bg-hover">Hover effect</div>
<!-- Overlays -->
<div class="tw-bg-bg-overlay">Modal overlay</div>
```
### ❌ DON'T - Use primitive colors directly
```html
<!-- Bad: These Tailwind classes don't exist (primitives not exposed) -->
<p class="tw-text-brand-900">Text</p>
<div class="tw-bg-brand-600">Background</div>
<!-- Bad: Using primitives with Tailwind bracket notation -->
<p class="tw-text-[var(--color-brand-600)]">Text</p>
<div class="tw-bg-[var(--color-success-700)]">Background</div>
<!-- Bad: Using primitive CSS variables bypasses the semantic layer -->
<span style="color: var(--color-brand-600)">Text</span>
<div style="background: var(--color-success-700)">Background</div>
```
**Why this is wrong:** Primitives aren't semantic and may change. Always use semantic tokens like
`tw-text-fg-brand`, `tw-bg-success`, etc.
---
## Dark Mode
- Semantic tokens automatically adapt to dark mode via `.theme_dark` class
- No component changes needed when theme switches
- The same semantic token name works in both light and dark themes
- All color values are automatically swapped based on the active theme
---
## Migration Strategy
- **New components:** Use semantic tokens (`fg-*`, `bg-*`, `border-*`) exclusively
- **Existing components:** Keep legacy tokens until refactoring
- **When refactoring:** Replace legacy tokens with semantic equivalents
---
## Legacy Colors
**Legacy colors (RGB format)** still exist for backwards compatibility:
- `primary-*`, `secondary-*`, `success-*`, `danger-*`, `warning-*`, etc.
- Use these only when updating existing components
- Migrate to new semantic tokens when refactoring
The following legacy colors are displayed below with both light and dark mode values:
export const LegacyCard = ({ name }) => {
return (
<div className="tw-flex tw-items-center tw-gap-3 tw-rounded-xl tw-p-4 tw-border tw-border-border-base tw-bg-bg-primary">
<div className="tw-flex-1 tw-min-w-0">
<div className="tw-font-mono tw-text-sm tw-font-semibold tw-text-fg-heading">{name}</div>
<div className="tw-text-xs tw-text-fg-body-subtle tw-mt-0.5">(legacy RGB format)</div>
</div>
<Swatch name={name} />
</div>
);
};
<div class="tw-grid tw-grid-cols-2 tw-gap-8">
<div class="tw-bg-bg-primary tw-p-6 tw-rounded-lg">
<h3 class="sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Light mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">General</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="background" />
<LegacyCard name="background-alt" />
<LegacyCard name="background-alt2" />
<LegacyCard name="background-alt3" />
<LegacyCard name="background-alt4" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Primary</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="primary-100" />
<LegacyCard name="primary-300" />
<LegacyCard name="primary-600" />
<LegacyCard name="primary-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">
Secondary
</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="secondary-100" />
<LegacyCard name="secondary-300" />
<LegacyCard name="secondary-500" />
<LegacyCard name="secondary-600" />
<LegacyCard name="secondary-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Success</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="success-100" />
<LegacyCard name="success-600" />
<LegacyCard name="success-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Danger</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="danger-100" />
<LegacyCard name="danger-600" />
<LegacyCard name="danger-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Warning</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="warning-100" />
<LegacyCard name="warning-600" />
<LegacyCard name="warning-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Info</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="info-100" />
<LegacyCard name="info-600" />
<LegacyCard name="info-700" />
</div>
</div>
</div>
<div class="theme_dark tw-bg-bg-primary tw-p-6 tw-rounded-lg">
<h3 class="sb-unstyled tw-mb-6 tw-text-xl tw-font-bold tw-text-fg-heading">Dark mode</h3>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">General</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="background" />
<LegacyCard name="background-alt" />
<LegacyCard name="background-alt2" />
<LegacyCard name="background-alt3" />
<LegacyCard name="background-alt4" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Primary</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="primary-100" />
<LegacyCard name="primary-300" />
<LegacyCard name="primary-600" />
<LegacyCard name="primary-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">
Secondary
</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="secondary-100" />
<LegacyCard name="secondary-300" />
<LegacyCard name="secondary-500" />
<LegacyCard name="secondary-600" />
<LegacyCard name="secondary-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Success</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="success-100" />
<LegacyCard name="success-600" />
<LegacyCard name="success-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Danger</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="danger-100" />
<LegacyCard name="danger-600" />
<LegacyCard name="danger-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Warning</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="warning-100" />
<LegacyCard name="warning-600" />
<LegacyCard name="warning-700" />
</div>
</div>
<div class="sb-unstyled tw-mb-6">
<h4 class="sb-unstyled tw-mb-3 tw-text-base tw-font-semibold tw-text-fg-heading">Info</h4>
<div class="tw-grid tw-grid-cols-1 tw-gap-2">
<LegacyCard name="info-100" />
<LegacyCard name="info-600" />
<LegacyCard name="info-700" />
</div>
</div>
</div>
</div>

View File

@@ -13,9 +13,11 @@ import {
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { LayoutComponent } from "../../layout";
import { I18nMockService } from "../../utils/i18n-mock.service";
import { StorybookGlobalStateProvider } from "../../utils/state-mock";
import { positionFixedWrapperDecorator } from "../storybook-decorators";
import { DialogVirtualScrollBlockComponent } from "./components/dialog-virtual-scroll-block.component";
@@ -65,9 +67,14 @@ export default {
yes: "Yes",
no: "No",
loading: "Loading",
resizeSideNavigation: "Resize side navigation",
});
},
},
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],

View File

@@ -1,13 +1,14 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
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 { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { TableDataSource } from "./table-data-source";
import { TableModule } from "./table.module";
@@ -27,6 +28,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
argTypes: {
alignRowContent: {

View File

@@ -5,7 +5,7 @@
[routerLinkActiveOptions]="routerLinkMatchOptions"
#rla="routerLinkActive"
[active]="rla.isActive"
[info]="{ focusMainAfterNav: false }"
[state]="{ focusMainAfterNav: false }"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
ariaCurrentWhenActive="page"

View File

@@ -13,6 +13,12 @@
@tailwind utilities;
:root {
/* ========================================
* LEGACY COLORS (RGB format)
* These are the original colors used throughout the app.
* Use these for existing components until migration is complete.
* ======================================== */
--color-transparent-hover: rgb(0 0 0 / 0.02);
--color-shadow: 168 179 200;
@@ -74,6 +80,279 @@
--color-illustration-bg-tertiary: 255 255 255;
--color-illustration-tertiary: 255 191 0;
--color-illustration-logo: 23 93 220;
/* ========================================
* NEW COLOR PALETTE (Hex format)
* These colors are from the new Figma design system.
* Use these for new components and features.
* Format: --color-{family}-{shade} where shade ranges from 050 to 950
* ======================================== */
/* Brand Colors */
--color-brand-050: #eef6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bedbff;
--color-brand-300: #8ec5ff;
--color-brand-400: #6baefa;
--color-brand-500: #418bfb;
--color-brand-600: #2a70f4;
--color-brand-700: #175ddc;
--color-brand-800: #0d43af;
--color-brand-900: #0c3276;
--color-brand-950: #162455;
/* Gray Colors */
--color-gray-050: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5dc;
--color-gray-400: #99a1af;
--color-gray-500: #6a7282;
--color-gray-600: #4a5565;
--color-gray-700: #333e4f;
--color-gray-800: #1e2939;
--color-gray-900: #101828;
--color-gray-950: #070b18;
--color-gray-950-rgb: 7, 11, 24;
/* Red Colors */
--color-red-050: #fef2f2;
--color-red-100: #ffe2e2;
--color-red-200: #ffc9c9;
--color-red-300: #ffa2a2;
--color-red-400: #ff6467;
--color-red-500: #fb2c36;
--color-red-600: #e7000b;
--color-red-700: #c10007;
--color-red-800: #9f0712;
--color-red-900: #791112;
--color-red-950: #460809;
/* Orange Colors */
--color-orange-050: #fff8f1;
--color-orange-100: #feecdc;
--color-orange-200: #fcd9bd;
--color-orange-300: #fdba8c;
--color-orange-400: #ff8a4c;
--color-orange-500: #ff5a1f;
--color-orange-600: #d03801;
--color-orange-700: #b43403;
--color-orange-800: #8a2c0d;
--color-orange-900: #70240b;
--color-orange-950: #441306;
/* Yellow Colors */
--color-yellow-050: #fefce8;
--color-yellow-100: #fef9c2;
--color-yellow-200: #fff085;
--color-yellow-300: #ffdf20;
--color-yellow-400: #fdc700;
--color-yellow-500: #f0b100;
--color-yellow-600: #d08700;
--color-yellow-700: #a65f00;
--color-yellow-800: #894b00;
--color-yellow-900: #733e0a;
--color-yellow-950: #432004;
/* Green Colors */
--color-green-050: #f0fdf4;
--color-green-100: #dcfce7;
--color-green-200: #b9f8cf;
--color-green-300: #7bf1a8;
--color-green-400: #18dc7a;
--color-green-500: #0abf52;
--color-green-600: #00a63e;
--color-green-700: #008236;
--color-green-800: #016630;
--color-green-900: #0d542b;
--color-green-950: #032e15;
/* Pink Colors */
--color-pink-050: #fdf2f8;
--color-pink-100: #fce7f3;
--color-pink-200: #fccee8;
--color-pink-300: #fda5d5;
--color-pink-400: #fb64b6;
--color-pink-500: #f6339a;
--color-pink-600: #e60076;
--color-pink-700: #c6005c;
--color-pink-800: #a3004c;
--color-pink-900: #861043;
--color-pink-950: #510424;
/* Coral Colors */
--color-coral-050: #fff2f0;
--color-coral-100: #ffe0dc;
--color-coral-200: #ffc1b9;
--color-coral-300: #ff9585;
--color-coral-400: #ff6550;
--color-coral-500: #ff4026;
--color-coral-600: #e11f05;
--color-coral-700: #c71800;
--color-coral-800: #a81400;
--color-coral-900: #7e0f00;
--color-coral-950: #4d0900;
/* Teal Colors */
--color-teal-050: #ecfeff;
--color-teal-100: #cefafe;
--color-teal-200: #a2f4fd;
--color-teal-300: #70ecf5;
--color-teal-400: #2cdde9;
--color-teal-500: #00c5db;
--color-teal-600: #009cb8;
--color-teal-700: #007c95;
--color-teal-800: #006278;
--color-teal-900: #0f495c;
--color-teal-950: #042e3e;
/* Purple Colors */
--color-purple-050: #faf5ff;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d4ff;
--color-purple-300: #dab2ff;
--color-purple-400: #c27aff;
--color-purple-500: #ad46ff;
--color-purple-600: #9810fa;
--color-purple-700: #8200db;
--color-purple-800: #6e11b0;
--color-purple-900: #59168b;
--color-purple-950: #3c0366;
/* White and Black */
--color-white: #ffffff;
--color-white-rgb: 255, 255, 255;
--color-black: #000000;
/* ========================================
* SEMANTIC FOREGROUND COLORS (Light Mode)
* These are the tokens that should be exposed to Tailwind
* They reference the primitive colors above
* ======================================== */
/* Neutral Foreground */
--color-fg-white: var(--color-white);
--color-fg-dark: var(--color-gray-900);
--color-fg-contrast: var(--color-white);
--color-fg-heading: var(--color-gray-900);
--color-fg-body: var(--color-gray-600);
--color-fg-body-subtle: var(--color-gray-500);
--color-fg-disabled: var(--color-gray-400);
/* Brand Foreground */
--color-fg-brand-soft: var(--color-brand-200);
--color-fg-brand: var(--color-brand-700);
--color-fg-brand-strong: var(--color-brand-900);
/* Status Foreground */
--color-fg-success: var(--color-green-700);
--color-fg-success-strong: var(--color-green-900);
--color-fg-danger: var(--color-red-700);
--color-fg-danger-strong: var(--color-red-900);
--color-fg-warning: var(--color-orange-600);
--color-fg-warning-strong: var(--color-orange-900);
--color-fg-sensitive: var(--color-pink-600);
/* Accent Foreground */
--color-fg-accent-primary-soft: var(--color-teal-200);
--color-fg-accent-primary: var(--color-teal-400);
--color-fg-accent-primary-strong: var(--color-teal-800);
--color-fg-accent-secondary-soft: var(--color-coral-200);
--color-fg-accent-secondary: var(--color-coral-400);
--color-fg-accent-secondary-strong: var(--color-coral-900);
--color-fg-accent-tertiary-soft: var(--color-purple-200);
--color-fg-accent-tertiary: var(--color-purple-700);
--color-fg-accent-tertiary-strong: var(--color-purple-900);
/* ========================================
* SEMANTIC BACKGROUND COLORS (Light Mode)
* ======================================== */
/* Neutral Background */
--color-bg-white: var(--color-white);
--color-bg-dark: var(--color-gray-800);
--color-bg-contrast: var(--color-gray-800);
--color-bg-contrast-strong: var(--color-gray-950);
--color-bg-primary: var(--color-white);
--color-bg-secondary: var(--color-gray-050);
--color-bg-tertiary: var(--color-gray-050);
--color-bg-quaternary: var(--color-gray-200);
--color-bg-gray: var(--color-gray-300);
--color-bg-disabled: var(--color-gray-100);
/* Brand Background */
--color-bg-brand-softer: var(--color-brand-050);
--color-bg-brand-soft: var(--color-brand-100);
--color-bg-brand-medium: var(--color-brand-200);
--color-bg-brand: var(--color-brand-700);
--color-bg-brand-strong: var(--color-brand-800);
/* Status Background */
--color-bg-success-soft: var(--color-green-050);
--color-bg-success-medium: var(--color-green-100);
--color-bg-success: var(--color-green-700);
--color-bg-success-strong: var(--color-green-800);
--color-bg-danger-soft: var(--color-red-050);
--color-bg-danger-medium: var(--color-red-100);
--color-bg-danger: var(--color-red-700);
--color-bg-danger-strong: var(--color-red-800);
--color-bg-warning-soft: var(--color-orange-050);
--color-bg-warning-medium: var(--color-orange-100);
--color-bg-warning: var(--color-orange-600);
--color-bg-warning-strong: var(--color-orange-700);
/* Accent Background */
--color-bg-accent-primary-soft: var(--color-teal-050);
--color-bg-accent-primary-medium: var(--color-teal-100);
--color-bg-accent-primary: var(--color-teal-400);
--color-bg-accent-secondary-soft: var(--color-coral-050);
--color-bg-accent-secondary-medium: var(--color-coral-100);
--color-bg-accent-secondary: var(--color-coral-400);
--color-bg-accent-tertiary-soft: var(--color-purple-050);
--color-bg-accent-tertiary-medium: var(--color-purple-100);
--color-bg-accent-tertiary: var(--color-purple-600);
/* Hover & Overlay */
--color-bg-hover: rgba(var(--color-gray-950-rgb), 0.05);
--color-bg-overlay: rgba(var(--color-gray-950-rgb), 0.3);
/* ========================================
* SEMANTIC BORDER COLORS (Light Mode)
* ======================================== */
/* Neutral Border */
--color-border-muted: var(--color-gray-050);
--color-border-light: var(--color-gray-100);
--color-border-base: var(--color-gray-200);
--color-border-strong: var(--color-gray-800);
--color-border-buffer: var(--color-white);
/* Brand Border */
--color-border-brand-soft: var(--color-brand-200);
--color-border-brand: var(--color-brand-700);
--color-border-brand-strong: var(--color-brand-900);
/* Status Border */
--color-border-success-soft: var(--color-green-200);
--color-border-success: var(--color-green-700);
--color-border-success-strong: var(--color-green-900);
--color-border-danger-soft: var(--color-red-200);
--color-border-danger: var(--color-red-700);
--color-border-danger-strong: var(--color-red-900);
--color-border-warning-soft: var(--color-orange-200);
--color-border-warning: var(--color-orange-600);
--color-border-warning-strong: var(--color-orange-900);
/* Accent Border */
--color-border-accent-primary-soft: var(--color-teal-200);
--color-border-accent-primary: var(--color-teal-600);
--color-border-accent-secondary-soft: var(--color-coral-200);
--color-border-accent-secondary: var(--color-coral-600);
--color-border-accent-tertiary-soft: var(--color-purple-200);
--color-border-accent-tertiary: var(--color-purple-600);
/* Focus Border */
--color-border-focus: var(--color-black);
}
.theme_light {
@@ -140,6 +419,129 @@
--color-illustration-bg-tertiary: 243 246 249;
--color-illustration-tertiary: 255 191 0;
--color-illustration-logo: 255 255 255;
/* ========================================
* SEMANTIC FOREGROUND COLORS (Dark Mode Overrides)
* ======================================== */
/* Neutral Foreground */
--color-fg-contrast: var(--color-gray-900);
--color-fg-heading: var(--color-gray-050);
--color-fg-body: var(--color-gray-200);
--color-fg-body-subtle: var(--color-gray-400);
--color-fg-disabled: var(--color-gray-600);
/* Brand Foreground */
--color-fg-brand-soft: var(--color-brand-500);
--color-fg-brand: var(--color-brand-400);
--color-fg-brand-strong: var(--color-brand-200);
/* Status Foreground */
--color-fg-success: var(--color-green-400);
--color-fg-success-strong: var(--color-green-100);
--color-fg-danger: var(--color-red-400);
--color-fg-danger-strong: var(--color-red-100);
--color-fg-warning: var(--color-orange-400);
--color-fg-warning-strong: var(--color-orange-100);
--color-fg-sensitive: var(--color-pink-300);
/* Accent Foreground */
--color-fg-accent-primary-soft: var(--color-teal-400);
--color-fg-accent-primary: var(--color-teal-300);
--color-fg-accent-primary-strong: var(--color-teal-100);
--color-fg-accent-secondary-soft: var(--color-coral-500);
--color-fg-accent-secondary: var(--color-coral-400);
--color-fg-accent-secondary-strong: var(--color-coral-100);
--color-fg-accent-tertiary-soft: var(--color-purple-500);
--color-fg-accent-tertiary: var(--color-purple-400);
--color-fg-accent-tertiary-strong: var(--color-purple-100);
/* ========================================
* SEMANTIC BACKGROUND COLORS (Dark Mode Overrides)
* ======================================== */
/* Neutral Background */
--color-bg-contrast: var(--color-gray-050);
--color-bg-contrast-strong: var(--color-gray-050);
--color-bg-primary: var(--color-gray-900);
--color-bg-secondary: var(--color-gray-800);
--color-bg-tertiary: var(--color-gray-950);
--color-bg-quaternary: var(--color-gray-700);
--color-bg-gray: var(--color-gray-600);
--color-bg-disabled: var(--color-gray-950);
/* Brand Background */
--color-bg-brand-softer: var(--color-brand-950);
--color-bg-brand-soft: var(--color-brand-900);
--color-bg-brand-medium: var(--color-brand-800);
--color-bg-brand: var(--color-brand-400);
--color-bg-brand-strong: var(--color-brand-300);
/* Status Background */
--color-bg-success-soft: var(--color-green-950);
--color-bg-success-medium: var(--color-green-900);
--color-bg-success: var(--color-green-400);
--color-bg-success-strong: var(--color-green-300);
--color-bg-danger-soft: var(--color-red-950);
--color-bg-danger-medium: var(--color-red-900);
--color-bg-danger: var(--color-red-400);
--color-bg-danger-strong: var(--color-red-300);
--color-bg-warning-soft: var(--color-orange-950);
--color-bg-warning-medium: var(--color-orange-900);
--color-bg-warning: var(--color-orange-400);
--color-bg-warning-strong: var(--color-orange-300);
/* Accent Background */
--color-bg-accent-primary-soft: var(--color-teal-950);
--color-bg-accent-primary-medium: var(--color-teal-900);
--color-bg-accent-primary: var(--color-teal-400);
--color-bg-accent-secondary-soft: var(--color-coral-950);
--color-bg-accent-secondary-medium: var(--color-coral-900);
--color-bg-accent-secondary: var(--color-coral-400);
--color-bg-accent-tertiary-soft: var(--color-purple-950);
--color-bg-accent-tertiary-medium: var(--color-purple-900);
--color-bg-accent-tertiary: var(--color-purple-600);
/* Hover & Overlay */
--color-bg-hover: rgba(var(--color-white-rgb), 0.05);
--color-bg-overlay: rgba(var(--color-gray-950-rgb), 0.85);
/* ========================================
* SEMANTIC BORDER COLORS (Dark Mode Overrides)
* ======================================== */
/* Neutral Border */
--color-border-muted: var(--color-gray-900);
--color-border-light: var(--color-gray-800);
--color-border-base: var(--color-gray-700);
--color-border-strong: var(--color-gray-400);
--color-border-buffer: var(--color-gray-950);
/* Brand Border */
--color-border-brand-soft: var(--color-brand-800);
--color-border-brand: var(--color-brand-400);
--color-border-brand-strong: var(--color-brand-200);
/* Status Border */
--color-border-success-soft: var(--color-green-800);
--color-border-success: var(--color-green-400);
--color-border-success-strong: var(--color-green-200);
--color-border-danger-soft: var(--color-red-800);
--color-border-danger: var(--color-red-400);
--color-border-danger-strong: var(--color-red-200);
--color-border-warning-soft: var(--color-orange-800);
--color-border-warning: var(--color-orange-400);
--color-border-warning-strong: var(--color-orange-200);
/* Accent Border */
--color-border-accent-primary-soft: var(--color-teal-800);
--color-border-accent-secondary-soft: var(--color-coral-800);
--color-border-accent-secondary: var(--color-coral-500);
--color-border-accent-tertiary-soft: var(--color-purple-800);
--color-border-accent-tertiary: var(--color-purple-500);
/* Focus Border */
--color-border-focus: var(--color-white);
}
@layer components {

View File

@@ -2,3 +2,4 @@ export * from "./aria-disable-element";
export * from "./function-to-observable";
export * from "./has-scrollable-content";
export * from "./i18n-mock.service";
export * from "./state-mock";

View File

@@ -0,0 +1,48 @@
import { BehaviorSubject, Observable } from "rxjs";
import {
GlobalState,
StateUpdateOptions,
GlobalStateProvider,
KeyDefinition,
} from "@bitwarden/state";
export class StorybookGlobalState<T> implements GlobalState<T> {
private _state$ = new BehaviorSubject<T | null>(null);
constructor(initialValue?: T | null) {
this._state$.next(initialValue ?? null);
}
async update<TCombine>(
configureState: (state: T | null, dependency: TCombine) => T | null,
options?: Partial<StateUpdateOptions<T, TCombine>>,
): Promise<T | null> {
const currentState = this._state$.value;
const newState = configureState(currentState, null as TCombine);
this._state$.next(newState);
return newState;
}
get state$(): Observable<T | null> {
return this._state$.asObservable();
}
setValue(value: T | null): void {
this._state$.next(value);
}
}
export class StorybookGlobalStateProvider implements GlobalStateProvider {
private states = new Map<string, StorybookGlobalState<any>>();
get<T>(keyDefinition: KeyDefinition<T>): GlobalState<T> {
const key = `${keyDefinition.fullName}_${keyDefinition.stateDefinition.defaultStorageLocation}`;
if (!this.states.has(key)) {
this.states.set(key, new StorybookGlobalState<T>());
}
return this.states.get(key)!;
}
}

View File

@@ -9,9 +9,9 @@ function rgba(color) {
module.exports = {
prefix: "tw-",
content: [
"./src/**/*.{html,ts}",
"./src/**/*.{html,ts,mdx}",
"../../libs/assets/src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts}",
"../../libs/components/src/**/*.{html,ts,mdx}",
"../../libs/key-management-ui/src/**/*.{html,ts}",
"../../libs/auth/src/**/*.{html,ts}",
],
@@ -78,6 +78,46 @@ module.exports = {
alt3: rgba("--color-background-alt3"),
alt4: rgba("--color-background-alt4"),
},
bg: {
white: "var(--color-bg-white)",
dark: "var(--color-bg-dark)",
contrast: "var(--color-bg-contrast)",
"contrast-strong": "var(--color-bg-contrast-strong)",
primary: "var(--color-bg-primary)",
secondary: "var(--color-bg-secondary)",
tertiary: "var(--color-bg-tertiary)",
quaternary: "var(--color-bg-quaternary)",
gray: "var(--color-bg-gray)",
disabled: "var(--color-bg-disabled)",
"brand-softer": "var(--color-bg-brand-softer)",
"brand-soft": "var(--color-bg-brand-soft)",
"brand-medium": "var(--color-bg-brand-medium)",
brand: "var(--color-bg-brand)",
"brand-strong": "var(--color-bg-brand-strong)",
"success-soft": "var(--color-bg-success-soft)",
"success-medium": "var(--color-bg-success-medium)",
success: "var(--color-bg-success)",
"success-strong": "var(--color-bg-success-strong)",
"danger-soft": "var(--color-bg-danger-soft)",
"danger-medium": "var(--color-bg-danger-medium)",
danger: "var(--color-bg-danger)",
"danger-strong": "var(--color-bg-danger-strong)",
"warning-soft": "var(--color-bg-warning-soft)",
"warning-medium": "var(--color-bg-warning-medium)",
warning: "var(--color-bg-warning)",
"warning-strong": "var(--color-bg-warning-strong)",
"accent-primary-soft": "var(--color-bg-accent-primary-soft)",
"accent-primary-medium": "var(--color-bg-accent-primary-medium)",
"accent-primary": "var(--color-bg-accent-primary)",
"accent-secondary-soft": "var(--color-bg-accent-secondary-soft)",
"accent-secondary-medium": "var(--color-bg-accent-secondary-medium)",
"accent-secondary": "var(--color-bg-accent-secondary)",
"accent-tertiary-soft": "var(--color-bg-accent-tertiary-soft)",
"accent-tertiary-medium": "var(--color-bg-accent-tertiary-medium)",
"accent-tertiary": "var(--color-bg-accent-tertiary)",
hover: "var(--color-bg-hover)",
overlay: "var(--color-bg-overlay)",
},
hover: {
default: "var(--color-hover-default)",
contrast: "var(--color-hover-contrast)",
@@ -92,8 +132,62 @@ module.exports = {
tertiary: rgba("--color-illustration-tertiary"),
logo: rgba("--color-illustration-logo"),
},
fg: {
white: "var(--color-fg-white)",
dark: "var(--color-fg-dark)",
contrast: "var(--color-fg-contrast)",
heading: "var(--color-fg-heading)",
body: "var(--color-fg-body)",
"body-subtle": "var(--color-fg-body-subtle)",
disabled: "var(--color-fg-disabled)",
"brand-soft": "var(--color-fg-brand-soft)",
brand: "var(--color-fg-brand)",
"brand-strong": "var(--color-fg-brand-strong)",
success: "var(--color-fg-success)",
"success-strong": "var(--color-fg-success-strong)",
danger: "var(--color-fg-danger)",
"danger-strong": "var(--color-fg-danger-strong)",
warning: "var(--color-fg-warning)",
"warning-strong": "var(--color-fg-warning-strong)",
sensitive: "var(--color-fg-sensitive)",
"accent-primary-soft": "var(--color-fg-accent-primary-soft)",
"accent-primary": "var(--color-fg-accent-primary)",
"accent-primary-strong": "var(--color-fg-accent-primary-strong)",
"accent-secondary-soft": "var(--color-fg-accent-secondary-soft)",
"accent-secondary": "var(--color-fg-accent-secondary)",
"accent-secondary-strong": "var(--color-fg-accent-secondary-strong)",
"accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)",
"accent-tertiary": "var(--color-fg-accent-tertiary)",
"accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)",
},
border: {
muted: "var(--color-border-muted)",
light: "var(--color-border-light)",
base: "var(--color-border-base)",
strong: "var(--color-border-strong)",
buffer: "var(--color-border-buffer)",
"brand-soft": "var(--color-border-brand-soft)",
brand: "var(--color-border-brand)",
"brand-strong": "var(--color-border-brand-strong)",
"success-soft": "var(--color-border-success-soft)",
success: "var(--color-border-success)",
"success-strong": "var(--color-border-success-strong)",
"danger-soft": "var(--color-border-danger-soft)",
danger: "var(--color-border-danger)",
"danger-strong": "var(--color-border-danger-strong)",
"warning-soft": "var(--color-border-warning-soft)",
warning: "var(--color-border-warning)",
"warning-strong": "var(--color-border-warning-strong)",
"accent-primary-soft": "var(--color-border-accent-primary-soft)",
"accent-primary": "var(--color-border-accent-primary)",
"accent-secondary-soft": "var(--color-border-accent-secondary-soft)",
"accent-secondary": "var(--color-border-accent-secondary)",
"accent-tertiary-soft": "var(--color-border-accent-tertiary-soft)",
"accent-tertiary": "var(--color-border-accent-tertiary)",
focus: "var(--color-border-focus)",
},
},
textColor: {
textColor: () => ({
main: rgba("--color-text-main"),
muted: rgba("--color-text-muted"),
contrast: rgba("--color-text-contrast"),
@@ -132,7 +226,62 @@ module.exports = {
notification: {
600: rgba("--color-notification-600"),
},
},
// New semantic fg tokens - manually flattened to generate tw-text-fg-* utilities
"fg-white": "var(--color-fg-white)",
"fg-dark": "var(--color-fg-dark)",
"fg-contrast": "var(--color-fg-contrast)",
"fg-heading": "var(--color-fg-heading)",
"fg-body": "var(--color-fg-body)",
"fg-body-subtle": "var(--color-fg-body-subtle)",
"fg-disabled": "var(--color-fg-disabled)",
"fg-brand-soft": "var(--color-fg-brand-soft)",
"fg-brand": "var(--color-fg-brand)",
"fg-brand-strong": "var(--color-fg-brand-strong)",
"fg-success": "var(--color-fg-success)",
"fg-success-strong": "var(--color-fg-success-strong)",
"fg-danger": "var(--color-fg-danger)",
"fg-danger-strong": "var(--color-fg-danger-strong)",
"fg-warning": "var(--color-fg-warning)",
"fg-warning-strong": "var(--color-fg-warning-strong)",
"fg-sensitive": "var(--color-fg-sensitive)",
"fg-accent-primary-soft": "var(--color-fg-accent-primary-soft)",
"fg-accent-primary": "var(--color-fg-accent-primary)",
"fg-accent-primary-strong": "var(--color-fg-accent-primary-strong)",
"fg-accent-secondary-soft": "var(--color-fg-accent-secondary-soft)",
"fg-accent-secondary": "var(--color-fg-accent-secondary)",
"fg-accent-secondary-strong": "var(--color-fg-accent-secondary-strong)",
"fg-accent-tertiary-soft": "var(--color-fg-accent-tertiary-soft)",
"fg-accent-tertiary": "var(--color-fg-accent-tertiary)",
"fg-accent-tertiary-strong": "var(--color-fg-accent-tertiary-strong)",
}),
borderColor: ({ theme }) => ({
...theme("colors"),
// New semantic border tokens - manually flattened to generate tw-border-border-* utilities
"border-muted": "var(--color-border-muted)",
"border-light": "var(--color-border-light)",
"border-base": "var(--color-border-base)",
"border-strong": "var(--color-border-strong)",
"border-buffer": "var(--color-border-buffer)",
"border-brand-soft": "var(--color-border-brand-soft)",
"border-brand": "var(--color-border-brand)",
"border-brand-strong": "var(--color-border-brand-strong)",
"border-success-soft": "var(--color-border-success-soft)",
"border-success": "var(--color-border-success)",
"border-success-strong": "var(--color-border-success-strong)",
"border-danger-soft": "var(--color-border-danger-soft)",
"border-danger": "var(--color-border-danger)",
"border-danger-strong": "var(--color-border-danger-strong)",
"border-warning-soft": "var(--color-border-warning-soft)",
"border-warning": "var(--color-border-warning)",
"border-warning-strong": "var(--color-border-warning-strong)",
"border-accent-primary-soft": "var(--color-border-accent-primary-soft)",
"border-accent-primary": "var(--color-border-accent-primary)",
"border-accent-secondary-soft": "var(--color-border-accent-secondary-soft)",
"border-accent-secondary": "var(--color-border-accent-secondary)",
"border-accent-tertiary-soft": "var(--color-border-accent-tertiary-soft)",
"border-accent-tertiary": "var(--color-border-accent-tertiary)",
"border-focus": "var(--color-border-focus)",
}),
fontFamily: {
sans: "var(--font-sans)",
serif: "var(--font-serif)",

View File

@@ -11,11 +11,16 @@ config.content = [
"bitwarden_license/bit-web/src/**/*.{html,ts,mdx}",
".storybook/preview.tsx",
];
// Safelist is required for dynamic color classes in Storybook color documentation (colors.mdx).
// Tailwind's JIT compiler cannot detect dynamically constructed class names like `tw-bg-${name}`,
// so we must explicitly safelist these patterns to ensure all color utilities are generated.
config.safelist = [
{
pattern: /tw-bg-(.*)/,
},
];
config.corePlugins.preflight = true;
module.exports = config;

View File

@@ -0,0 +1,201 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { filter, firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Collection } from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import {
CipherWithIdExport,
CollectionWithIdExport,
FolderWithIdExport,
} from "@bitwarden/common/models/export";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { KeyService } from "@bitwarden/key-management";
import { UserId } from "@bitwarden/user-core";
import {
BitwardenEncryptedIndividualJsonExport,
BitwardenEncryptedJsonExport,
BitwardenEncryptedOrgJsonExport,
BitwardenJsonExport,
BitwardenPasswordProtectedFileFormat,
isOrgEncrypted,
isPasswordProtected,
isUnencrypted,
} from "@bitwarden/vault-export-core";
import { ImportResult } from "../../models/import-result";
import { Importer } from "../importer";
import { BitwardenJsonImporter } from "./bitwarden-json-importer";
export class BitwardenEncryptedJsonImporter extends BitwardenJsonImporter implements Importer {
constructor(
protected keyService: KeyService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
private cipherService: CipherService,
private accountService: AccountService,
) {
super();
}
async parse(data: string): Promise<ImportResult> {
const results: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport = JSON.parse(data);
if (isPasswordProtected(results)) {
throw new Error(
"Data is password-protected. Use BitwardenPasswordProtectedImporter instead.",
);
}
if (results == null || results.items == null) {
const result = new ImportResult();
result.success = false;
return result;
}
if (isUnencrypted(results)) {
return super.parse(data);
}
return await this.parseEncrypted(results);
}
private async parseEncrypted(data: BitwardenEncryptedJsonExport): Promise<ImportResult> {
const result = new ImportResult();
const account = await firstValueFrom(this.accountService.activeAccount$);
if (this.isNullOrWhitespace(data.encKeyValidation_DO_NOT_EDIT)) {
result.success = false;
result.errorMessage = this.i18nService.t("importEncKeyError");
return result;
}
const orgKeys = await firstValueFrom(this.keyService.orgKeys$(account.id));
let keyForDecryption: OrgKey | UserKey | null | undefined = orgKeys?.[this.organizationId];
if (!keyForDecryption) {
keyForDecryption = await firstValueFrom(this.keyService.userKey$(account.id));
}
if (!keyForDecryption) {
result.success = false;
result.errorMessage = this.i18nService.t("importEncKeyError");
return result;
}
const encKeyValidation = new EncString(data.encKeyValidation_DO_NOT_EDIT);
try {
await this.encryptService.decryptString(encKeyValidation, keyForDecryption);
} catch {
result.success = false;
result.errorMessage = this.i18nService.t("importEncKeyError");
return result;
}
let groupingsMap: Map<string, number> | null = null;
if (isOrgEncrypted(data)) {
groupingsMap = await this.parseEncryptedCollections(account.id, data, result);
} else {
groupingsMap = await this.parseEncryptedFolders(account.id, data, result);
}
for (const c of data.items) {
const cipher = CipherWithIdExport.toDomain(c);
// reset ids in case they were set for some reason
cipher.id = null;
cipher.organizationId = this.organizationId;
cipher.collectionIds = null;
// make sure password history is limited
if (cipher.passwordHistory != null && cipher.passwordHistory.length > 5) {
cipher.passwordHistory = cipher.passwordHistory.slice(0, 5);
}
if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) {
result.folderRelationships.push([result.ciphers.length, groupingsMap.get(c.folderId)]);
} else if (this.organization && c.collectionIds != null) {
c.collectionIds.forEach((cId) => {
if (groupingsMap.has(cId)) {
result.collectionRelationships.push([result.ciphers.length, groupingsMap.get(cId)]);
}
});
}
const view = await this.cipherService.decrypt(cipher, account.id);
this.cleanupCipher(view);
result.ciphers.push(view);
}
result.success = true;
return result;
}
private async parseEncryptedFolders(
userId: UserId,
data: BitwardenEncryptedIndividualJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
if (data.folders == null) {
return groupingsMap;
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
for (const f of data.folders) {
let folderView: FolderView;
const folder = FolderWithIdExport.toDomain(f);
if (folder != null) {
folderView = await folder.decrypt(userKey);
}
if (folderView != null) {
groupingsMap.set(f.id, importResult.folders.length);
importResult.folders.push(folderView);
}
}
return groupingsMap;
}
private async parseEncryptedCollections(
userId: UserId,
data: BitwardenEncryptedOrgJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
if (data.collections == null) {
return groupingsMap;
}
const orgKeys = await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)),
);
for (const c of data.collections) {
const collection = CollectionWithIdExport.toDomain(
c,
new Collection({
id: c.id,
name: new EncString(c.name),
organizationId: this.organizationId,
}),
);
const orgKey = orgKeys[c.organizationId];
const collectionView = await collection.decrypt(orgKey, this.encryptService);
if (collectionView != null) {
groupingsMap.set(c.id, importResult.collections.length);
importResult.collections.push(collectionView);
}
}
return groupingsMap;
}
}

View File

@@ -1,30 +1,17 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { filter, firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Collection, CollectionView } from "@bitwarden/admin-console/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import {
CipherWithIdExport,
CollectionWithIdExport,
FolderWithIdExport,
} from "@bitwarden/common/models/export";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { KeyService } from "@bitwarden/key-management";
import {
BitwardenEncryptedIndividualJsonExport,
BitwardenEncryptedOrgJsonExport,
BitwardenJsonExport,
BitwardenUnEncryptedIndividualJsonExport,
BitwardenUnEncryptedJsonExport,
BitwardenUnEncryptedOrgJsonExport,
isOrgUnEncrypted,
isUnencrypted,
} from "@bitwarden/vault-export-core";
import { ImportResult } from "../../models/import-result";
@@ -32,103 +19,30 @@ import { BaseImporter } from "../base-importer";
import { Importer } from "../importer";
export class BitwardenJsonImporter extends BaseImporter implements Importer {
private result: ImportResult;
protected constructor(
protected keyService: KeyService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected cipherService: CipherService,
protected accountService: AccountService,
) {
protected constructor() {
super();
}
async parse(data: string): Promise<ImportResult> {
const account = await firstValueFrom(this.accountService.activeAccount$);
this.result = new ImportResult();
const results: BitwardenJsonExport = JSON.parse(data);
if (results == null || results.items == null) {
this.result.success = false;
return this.result;
const result = new ImportResult();
result.success = false;
return result;
}
if (results.encrypted) {
await this.parseEncrypted(results as any, account.id);
} else {
await this.parseDecrypted(results as any, account.id);
if (!isUnencrypted(results)) {
throw new Error("Data is encrypted. Use BitwardenEncryptedJsonImporter instead.");
}
return this.result;
return await this.parseDecrypted(results);
}
private async parseEncrypted(
results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport,
userId: UserId,
) {
if (results.encKeyValidation_DO_NOT_EDIT != null) {
const orgKeys = await firstValueFrom(this.keyService.orgKeys$(userId));
let keyForDecryption: SymmetricCryptoKey = orgKeys?.[this.organizationId];
if (keyForDecryption == null) {
keyForDecryption = await firstValueFrom(this.keyService.userKey$(userId));
}
const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT);
try {
await this.encryptService.decryptString(encKeyValidation, keyForDecryption);
} catch {
this.result.success = false;
this.result.errorMessage = this.i18nService.t("importEncKeyError");
return;
}
}
private async parseDecrypted(results: BitwardenUnEncryptedJsonExport): Promise<ImportResult> {
const importResult = new ImportResult();
const groupingsMap = this.organization
? await this.parseCollections(results as BitwardenEncryptedOrgJsonExport, userId)
: await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport, userId);
for (const c of results.items) {
const cipher = CipherWithIdExport.toDomain(c);
// reset ids in case they were set for some reason
cipher.id = null;
cipher.organizationId = this.organizationId;
cipher.collectionIds = null;
// make sure password history is limited
if (cipher.passwordHistory != null && cipher.passwordHistory.length > 5) {
cipher.passwordHistory = cipher.passwordHistory.slice(0, 5);
}
if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) {
this.result.folderRelationships.push([
this.result.ciphers.length,
groupingsMap.get(c.folderId),
]);
} else if (this.organization && c.collectionIds != null) {
c.collectionIds.forEach((cId) => {
if (groupingsMap.has(cId)) {
this.result.collectionRelationships.push([
this.result.ciphers.length,
groupingsMap.get(cId),
]);
}
});
}
const view = await this.cipherService.decrypt(cipher, userId);
this.cleanupCipher(view);
this.result.ciphers.push(view);
}
this.result.success = true;
}
private async parseDecrypted(
results: BitwardenUnEncryptedIndividualJsonExport | BitwardenUnEncryptedOrgJsonExport,
userId: UserId,
) {
const groupingsMap = this.organization
? await this.parseCollections(results as BitwardenUnEncryptedOrgJsonExport, userId)
: await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport, userId);
const groupingsMap = isOrgUnEncrypted(results)
? await this.parseCollections(results, importResult)
: await this.parseFolders(results, importResult);
results.items.forEach((c) => {
const cipher = CipherWithIdExport.toView(c);
@@ -143,15 +57,15 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
}
if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) {
this.result.folderRelationships.push([
this.result.ciphers.length,
importResult.folderRelationships.push([
importResult.ciphers.length,
groupingsMap.get(c.folderId),
]);
} else if (this.organization && c.collectionIds != null) {
c.collectionIds.forEach((cId) => {
if (groupingsMap.has(cId)) {
this.result.collectionRelationships.push([
this.result.ciphers.length,
importResult.collectionRelationships.push([
importResult.ciphers.length,
groupingsMap.get(cId),
]);
}
@@ -159,79 +73,48 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
}
this.cleanupCipher(cipher);
this.result.ciphers.push(cipher);
importResult.ciphers.push(cipher);
});
this.result.success = true;
importResult.success = true;
return importResult;
}
private async parseFolders(
data: BitwardenUnEncryptedIndividualJsonExport | BitwardenEncryptedIndividualJsonExport,
userId: UserId,
): Promise<Map<string, number>> | null {
data: BitwardenUnEncryptedIndividualJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
if (data.folders == null) {
return null;
return groupingsMap;
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
const groupingsMap = new Map<string, number>();
for (const f of data.folders) {
let folderView: FolderView;
if (data.encrypted) {
const folder = FolderWithIdExport.toDomain(f);
if (folder != null) {
folderView = await folder.decrypt(userKey);
}
} else {
folderView = FolderWithIdExport.toView(f);
}
const folderView = FolderWithIdExport.toView(f);
if (folderView != null) {
groupingsMap.set(f.id, this.result.folders.length);
this.result.folders.push(folderView);
groupingsMap.set(f.id, importResult.folders.length);
importResult.folders.push(folderView);
}
}
return groupingsMap;
}
private async parseCollections(
data: BitwardenUnEncryptedOrgJsonExport | BitwardenEncryptedOrgJsonExport,
userId: UserId,
): Promise<Map<string, number>> | null {
data: BitwardenUnEncryptedOrgJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
if (data.collections == null) {
return null;
return groupingsMap;
}
const orgKeys = await firstValueFrom(
this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)),
);
const groupingsMap = new Map<string, number>();
for (const c of data.collections) {
let collectionView: CollectionView;
if (data.encrypted) {
const collection = CollectionWithIdExport.toDomain(
c,
new Collection({
id: c.id,
name: new EncString(c.name),
organizationId: this.organizationId,
}),
);
const orgKey = orgKeys[c.organizationId];
collectionView = await collection.decrypt(orgKey, this.encryptService);
} else {
collectionView = CollectionWithIdExport.toView(c);
collectionView.organizationId = null;
}
const collectionView = CollectionWithIdExport.toView(c);
collectionView.organizationId = null;
if (collectionView != null) {
groupingsMap.set(c.id, this.result.collections.length);
this.result.collections.push(collectionView);
groupingsMap.set(c.id, importResult.collections.length);
importResult.collections.push(collectionView);
}
}
return groupingsMap;

View File

@@ -16,6 +16,7 @@ import { UserId } from "@bitwarden/user-core";
import { emptyAccountEncrypted } from "../spec-data/bitwarden-json/account-encrypted.json";
import { emptyUnencryptedExport } from "../spec-data/bitwarden-json/unencrypted.json";
import { BitwardenEncryptedJsonImporter } from "./bitwarden-encrypted-json-importer";
import { BitwardenJsonImporter } from "./bitwarden-json-importer";
import { BitwardenPasswordProtectedImporter } from "./bitwarden-password-protected-importer";
@@ -92,7 +93,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
describe("Account encrypted", () => {
beforeAll(() => {
jest.spyOn(BitwardenJsonImporter.prototype, "parse");
jest.spyOn(BitwardenEncryptedJsonImporter.prototype, "parse");
});
beforeEach(() => {
@@ -114,9 +115,11 @@ describe("BitwardenPasswordProtectedImporter", () => {
);
});
it("Should call BitwardenJsonImporter", async () => {
expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(true);
expect(BitwardenJsonImporter.prototype.parse).toHaveBeenCalledWith(emptyAccountEncrypted);
it("Should call BitwardenEncryptedJsonImporter", async () => {
expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(false);
expect(BitwardenEncryptedJsonImporter.prototype.parse).toHaveBeenCalledWith(
emptyAccountEncrypted,
);
});
});

View File

@@ -14,14 +14,21 @@ import {
KeyService,
KdfType,
} from "@bitwarden/key-management";
import { BitwardenPasswordProtectedFileFormat } from "@bitwarden/vault-export-core";
import {
BitwardenJsonExport,
BitwardenPasswordProtectedFileFormat,
isPasswordProtected,
} from "@bitwarden/vault-export-core";
import { ImportResult } from "../../models/import-result";
import { Importer } from "../importer";
import { BitwardenJsonImporter } from "./bitwarden-json-importer";
import { BitwardenEncryptedJsonImporter } from "./bitwarden-encrypted-json-importer";
export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter implements Importer {
export class BitwardenPasswordProtectedImporter
extends BitwardenEncryptedJsonImporter
implements Importer
{
private key: SymmetricCryptoKey;
constructor(
@@ -38,20 +45,14 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
async parse(data: string): Promise<ImportResult> {
const result = new ImportResult();
const parsedData: BitwardenPasswordProtectedFileFormat = JSON.parse(data);
const parsedData: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport = JSON.parse(data);
if (!parsedData) {
result.success = false;
return result;
}
// File is unencrypted
if (!parsedData?.encrypted) {
return await super.parse(data);
}
// File is account-encrypted
if (!parsedData?.passwordProtected) {
if (!isPasswordProtected(parsedData)) {
return await super.parse(data);
}

View File

@@ -103,6 +103,7 @@ export const AUTOTYPE_SETTINGS_DISK = new StateDefinition("autotypeSettings", "d
export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanner", "disk", {
web: "disk-local",
});
export const BIT_SIDE_NAV_DISK = new StateDefinition("bitSideNav", "disk");
// DIRT

View File

@@ -5,42 +5,48 @@ import {
} from "@bitwarden/common/models/export";
// Base
export type BitwardenJsonExport = {
encrypted: boolean;
items: CipherWithIdExport[];
};
export type BitwardenJsonExport = BitwardenUnEncryptedJsonExport | BitwardenEncryptedJsonExport;
// Decrypted
export type BitwardenUnEncryptedJsonExport = BitwardenJsonExport & {
encrypted: false;
};
export type BitwardenUnEncryptedJsonExport =
| BitwardenUnEncryptedIndividualJsonExport
| BitwardenUnEncryptedOrgJsonExport;
export type BitwardenUnEncryptedIndividualJsonExport = BitwardenUnEncryptedJsonExport & {
export type BitwardenUnEncryptedIndividualJsonExport = {
encrypted: false;
items: CipherWithIdExport[];
folders: FolderWithIdExport[];
};
export type BitwardenUnEncryptedOrgJsonExport = BitwardenUnEncryptedJsonExport & {
export type BitwardenUnEncryptedOrgJsonExport = {
encrypted: false;
items: CipherWithIdExport[];
collections: CollectionWithIdExport[];
};
// Account-encrypted
export type BitwardenEncryptedJsonExport = BitwardenJsonExport & {
export type BitwardenEncryptedJsonExport =
| BitwardenEncryptedIndividualJsonExport
| BitwardenEncryptedOrgJsonExport;
export type BitwardenEncryptedIndividualJsonExport = {
encrypted: true;
encKeyValidation_DO_NOT_EDIT: string;
};
export type BitwardenEncryptedIndividualJsonExport = BitwardenEncryptedJsonExport & {
items: CipherWithIdExport[];
folders: FolderWithIdExport[];
};
export type BitwardenEncryptedOrgJsonExport = BitwardenEncryptedJsonExport & {
export type BitwardenEncryptedOrgJsonExport = {
encrypted: true;
encKeyValidation_DO_NOT_EDIT: string;
items: CipherWithIdExport[];
collections: CollectionWithIdExport[];
};
// Password-protected
export type BitwardenPasswordProtectedFileFormat = {
encrypted: boolean;
passwordProtected: boolean;
encrypted: true;
passwordProtected: true;
salt: string;
kdfIterations: number;
kdfMemory?: number;
@@ -49,3 +55,50 @@ export type BitwardenPasswordProtectedFileFormat = {
encKeyValidation_DO_NOT_EDIT: string;
data: string;
};
// Unencrypted type guards
export function isUnencrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenUnEncryptedJsonExport {
return data != null && (data as { encrypted?: unknown }).encrypted !== true;
}
export function isIndividualUnEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenUnEncryptedIndividualJsonExport {
return isUnencrypted(data) && (data as { folders?: unknown }).folders != null;
}
export function isOrgUnEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenUnEncryptedOrgJsonExport {
return isUnencrypted(data) && (data as { collections?: unknown }).collections != null;
}
// Encrypted type guards
export function isEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenEncryptedJsonExport {
return data != null && (data as { encrypted?: unknown }).encrypted === true;
}
export function isPasswordProtected(
data: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport | null | undefined,
): data is BitwardenPasswordProtectedFileFormat {
return (
data != null &&
(data as { encrypted?: unknown }).encrypted === true &&
(data as { passwordProtected?: unknown }).passwordProtected === true
);
}
export function isIndividualEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenEncryptedIndividualJsonExport {
return isEncrypted(data) && (data as { folders?: unknown }).folders != null;
}
export function isOrgEncrypted(
data: BitwardenJsonExport | null | undefined,
): data is BitwardenEncryptedOrgJsonExport {
return isEncrypted(data) && (data as { collections?: unknown }).collections != null;
}

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import { SendFormContainer } from "../../send-form-container";
import { SendOptionsComponent } from "./send-options.component";
describe("SendOptionsComponent", () => {
let component: SendOptionsComponent;
let fixture: ComponentFixture<SendOptionsComponent>;
const mockSendFormContainer = mock<SendFormContainer>();
const mockAccountService = mock<AccountService>();
beforeAll(() => {
mockAccountService.activeAccount$ = of({ id: "myTestAccount" } as Account);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SendOptionsComponent],
declarations: [],
providers: [
{ provide: SendFormContainer, useValue: mockSendFormContainer },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: CredentialGeneratorService, useValue: mock<CredentialGeneratorService>() },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(SendOptionsComponent);
component = fixture.componentInstance;
component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text };
fixture.detectChanges();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should emit a null password when password textbox is empty", async () => {
const newSend = {} as SendView;
mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend));
component.sendOptionsForm.patchValue({ password: "testing" });
expect(newSend.password).toBe("testing");
component.sendOptionsForm.patchValue({ password: "" });
expect(newSend.password).toBe(null);
});
});

View File

@@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -12,6 +12,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { pin } from "@bitwarden/common/tools/rx";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@@ -112,18 +113,27 @@ export class SendOptionsComponent implements OnInit {
this.disableHideEmail = disableHideEmail;
});
this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
this.sendOptionsForm.valueChanges
.pipe(
tap((value) => {
if (Utils.isNullOrWhitespace(value.password)) {
value.password = null;
}
}),
takeUntilDestroyed(),
)
.subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
});
return send;
});
return send;
});
});
}
generatePassword = async () => {

View File

@@ -18,7 +18,6 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import {
@@ -227,10 +226,6 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
return;
}
if (Utils.isNullOrWhitespace(this.updatedSendView.password)) {
this.updatedSendView.password = null;
}
this.toastService.showToast({
variant: "success",
title: null,

View File

@@ -74,6 +74,9 @@ export class RoutedVaultFilterService implements OnDestroy {
type: filter.type ?? null,
},
queryParamsHandling: "merge",
state: {
focusMainAfterNav: false,
},
};
return [commands, extras];
}