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:
@@ -1146,6 +1146,10 @@ const safeProviders: SafeProvider[] = [
|
||||
KeyGenerationService,
|
||||
LOGOUT_CALLBACK,
|
||||
StateProvider,
|
||||
ConfigService,
|
||||
RegisterSdkService,
|
||||
SecurityStateService,
|
||||
AccountCryptographicStateService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ import { KdfConfig } from "@bitwarden/key-management";
|
||||
export interface NewSsoUserKeyConnectorConversion {
|
||||
kdfConfig: KdfConfig;
|
||||
keyConnectorUrl: string;
|
||||
// SSO organization identifier, not UUID
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -5,4 +5,5 @@ export const mockLayoutI18n = {
|
||||
submenu: "submenu",
|
||||
toggleCollapse: "toggle collapse",
|
||||
loading: "Loading",
|
||||
resizeSideNavigation: "Resize side navigation",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
48
libs/components/src/utils/state-mock.ts
Normal file
48
libs/components/src/utils/state-mock.ts
Normal 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)!;
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,9 @@ export class RoutedVaultFilterService implements OnDestroy {
|
||||
type: filter.type ?? null,
|
||||
},
|
||||
queryParamsHandling: "merge",
|
||||
state: {
|
||||
focusMainAfterNav: false,
|
||||
},
|
||||
};
|
||||
return [commands, extras];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user