1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-14 07:23:45 +00:00

Merge branch 'main' into desktop/send-search-on-push

This commit is contained in:
Robyn MacCallum
2026-01-23 14:10:39 -05:00
committed by GitHub
425 changed files with 13183 additions and 3629 deletions

View File

@@ -264,6 +264,13 @@ export abstract class OrganizationUserApiService {
ids: string[],
): Promise<ListResponse<OrganizationUserBulkResponse>>;
/**
* Revoke the current user's access to the organization
* if they decline an item transfer under the Organization Data Ownership policy.
* @param organizationId - Identifier for the organization the user belongs to
*/
abstract revokeSelf(organizationId: string): Promise<void>;
/**
* Restore an organization user's access to the organization
* @param organizationId - Identifier for the organization the user belongs to

View File

@@ -339,6 +339,16 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
return new ListResponse(r, OrganizationUserBulkResponse);
}
revokeSelf(organizationId: string): Promise<void> {
return this.apiService.send(
"PUT",
"/organizations/" + organizationId + "/users/revoke-self",
null,
true,
false,
);
}
restoreOrganizationUser(organizationId: string, id: string): Promise<void> {
return this.apiService.send(
"PUT",

View File

@@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { concatMap, 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
@@ -19,19 +19,32 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
fromSdkKdfConfig,
KdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal";
import {
SetInitialPasswordService,
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordUserType,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -47,6 +60,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {}
async setInitialPassword(
@@ -199,6 +213,126 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
}
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
if (userId == null) {
throw new Error("User ID is required.");
}
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} is required.`);
}
}
const { newPasswordHint, orgSsoIdentifier, orgId, resetPasswordAutoEnroll, newPassword, salt } =
credentials;
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error("Organization keys response is null.");
}
const registerResult = 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_jit_password_registration({
org_id: asUuid<SdkOrganizationId>(orgId),
org_public_key: organizationKeys.publicKey,
master_password: newPassword,
master_password_hint: newPasswordHint,
salt: salt,
organization_sso_identifier: orgSsoIdentifier,
user_id: asUuid<SdkUserId>(userId),
reset_password_enroll: resetPasswordAutoEnroll,
});
}),
),
);
if (!("V2" in registerResult.account_cryptographic_state)) {
throw new Error("Unexpected V2 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(
registerResult.account_cryptographic_state,
userId,
);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
const masterPasswordUnlockData = MasterPasswordUnlockData.fromSdk(
registerResult.master_password_unlock,
);
await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(registerResult.user_key) as UserKey,
userId,
);
await this.updateLegacyState(
newPassword,
fromSdkKdfConfig(registerResult.master_password_unlock.kdf),
new EncString(registerResult.master_password_unlock.masterKeyWrappedUserKey),
userId,
masterPasswordUnlockData,
);
}
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
@@ -244,6 +378,37 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
}
// Deprecated legacy support - to be removed in future
private async updateLegacyState(
newPassword: string,
kdfConfig: KdfConfig,
masterKeyWrappedUserKey: EncString,
userId: UserId,
masterPasswordUnlockData: MasterPasswordUnlockData,
) {
// TODO Remove HasMasterPassword from UserDecryptionOptions https://bitwarden.atlassian.net/browse/PM-23475
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
userDecryptionOpts,
);
// TODO Remove KDF state https://bitwarden.atlassian.net/browse/PM-30661
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
// TODO Remove master key memory state https://bitwarden.atlassian.net/browse/PM-23477
await this.masterPasswordService.setMasterKeyEncryptedUserKey(masterKeyWrappedUserKey, userId);
// TODO Removed with https://bitwarden.atlassian.net/browse/PM-30676
await this.masterPasswordService.setLegacyMasterKeyFromUnlockData(
newPassword,
masterPasswordUnlockData,
userId,
);
}
/**
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
@@ -310,44 +475,4 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
}

View File

@@ -1,5 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
// Polyfill for Symbol.dispose required by the service's use of `using` keyword
import "core-js/proposals/explicit-resource-management";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, Observable, of } 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
@@ -27,17 +30,35 @@ import {
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { Rc } from "@bitwarden/common/platform/misc/reference-counting/rc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
DEFAULT_KDF_CONFIG,
fromSdkKdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import {
AuthClient,
BitwardenClient,
WrappedAccountCryptographicState,
} from "@bitwarden/sdk-internal";
import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -58,6 +79,7 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
const registerSdkService = mock<RegisterSdkService>();
let userId: UserId;
let userKey: UserKey;
@@ -94,6 +116,7 @@ describe("DefaultSetInitialPasswordService", () => {
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -834,4 +857,246 @@ describe("DefaultSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption()", () => {
let mockSdkRef: {
value: MockProxy<BitwardenClient>;
[Symbol.dispose]: jest.Mock;
};
let mockSdk: {
take: jest.Mock;
};
let mockRegistration: jest.Mock;
const userId = "d4e2e3a1-1b5e-4c3b-8d7a-9f8e7d6c5b4a" as UserId;
const orgId = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" as OrganizationId;
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "test-hint",
orgSsoIdentifier: "org-sso-id",
orgId: orgId,
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as unknown as MasterPasswordSalt,
};
const orgKeys: OrganizationKeysResponse = {
publicKey: "org-public-key-base64",
privateKey: "org-private-key-encrypted",
} as OrganizationKeysResponse;
const sdkRegistrationResult = {
account_cryptographic_state: {
V2: {
private_key: makeEncString().encryptedString!,
signed_public_key: "test-signed-public-key",
signing_key: makeEncString().encryptedString!,
security_state: "test-security-state",
},
},
master_password_unlock: {
kdf: {
pBKDF2: {
iterations: 600000,
},
},
masterKeyWrappedUserKey: makeEncString().encryptedString!,
salt: "user@example.com" as unknown as MasterPasswordSalt,
},
user_key: makeSymmetricCryptoKey(64).keyB64,
};
beforeEach(() => {
jest.clearAllMocks();
mockSdkRef = {
value: mock<BitwardenClient>(),
[Symbol.dispose]: jest.fn(),
};
mockSdkRef.value.auth.mockReturnValue({
registration: jest.fn().mockReturnValue({
post_keys_for_jit_password_registration: jest.fn(),
}),
} as unknown as AuthClient);
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$.mockReturnValue(
of(mockSdk) as unknown as Observable<Rc<BitwardenClient>>,
);
organizationApiService.getKeys.mockResolvedValue(orgKeys);
mockRegistration = mockSdkRef.value.auth().registration()
.post_keys_for_jit_password_registration as unknown as jest.Mock;
mockRegistration.mockResolvedValue(sdkRegistrationResult);
const mockUserDecryptionOpts = new UserDecryptionOptions({ hasMasterPassword: false });
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(mockUserDecryptionOpts),
);
});
it("should successfully initialize JIT password user", async () => {
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(userId);
expect(mockRegistration).toHaveBeenCalledWith(
expect.objectContaining({
org_id: credentials.orgId,
org_public_key: orgKeys.publicKey,
master_password: credentials.newPassword,
master_password_hint: credentials.newPasswordHint,
salt: credentials.salt,
organization_sso_identifier: credentials.orgSsoIdentifier,
user_id: userId,
reset_password_enroll: credentials.resetPasswordAutoEnroll,
}),
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
sdkRegistrationResult.account_cryptographic_state,
userId,
);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
SymmetricCryptoKey.fromString(sdkRegistrationResult.user_key) as UserKey,
userId,
);
// Verify legacy state updates below
expect(userDecryptionOptionsService.userDecryptionOptionsById$).toHaveBeenCalledWith(userId);
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
expect.objectContaining({ hasMasterPassword: true }),
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(
userId,
fromSdkKdfConfig(sdkRegistrationResult.master_password_unlock.kdf),
);
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
new EncString(sdkRegistrationResult.master_password_unlock.masterKeyWrappedUserKey),
userId,
);
expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
userId,
);
});
describe("input validation", () => {
it.each([
"newPasswordHint",
"orgSsoIdentifier",
"orgId",
"resetPasswordAutoEnroll",
"newPassword",
"salt",
])("should throw error when %s is null", async (field) => {
const invalidCredentials = {
...credentials,
[field]: null,
} as unknown as InitializeJitPasswordCredentials;
const promise = sut.initializePasswordJitPasswordUserV2Encryption(
invalidCredentials,
userId,
);
await expect(promise).rejects.toThrow(`${field} is required.`);
expect(organizationApiService.getKeys).not.toHaveBeenCalled();
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
it("should throw error when userId is null", async () => {
const nullUserId = null as unknown as UserId;
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, nullUserId);
await expect(promise).rejects.toThrow("User ID is required.");
expect(organizationApiService.getKeys).not.toHaveBeenCalled();
});
});
describe("organization API error handling", () => {
it("should throw when organizationApiService.getKeys returns null", async () => {
organizationApiService.getKeys.mockResolvedValue(
null as unknown as OrganizationKeysResponse,
);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("Organization keys response is null.");
expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
it("should throw when organizationApiService.getKeys rejects", async () => {
const apiError = new Error("API network error");
organizationApiService.getKeys.mockRejectedValue(apiError);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("API network error");
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
});
describe("SDK error handling", () => {
it("should throw when SDK is not available", async () => {
organizationApiService.getKeys.mockResolvedValue(orgKeys);
registerSdkService.registerClient$.mockReturnValue(
of(null) as unknown as Observable<Rc<BitwardenClient>>,
);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("SDK not available");
});
it("should throw when SDK registration fails", async () => {
const sdkError = new Error("SDK crypto operation failed");
organizationApiService.getKeys.mockResolvedValue(orgKeys);
mockRegistration.mockRejectedValue(sdkError);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("SDK crypto operation failed");
});
});
it("should throw when account_cryptographic_state is not V2", async () => {
const invalidResult = {
...sdkRegistrationResult,
account_cryptographic_state: { V1: {} } as unknown as WrappedAccountCryptographicState,
};
mockRegistration.mockResolvedValue(invalidResult);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state");
});
});
});

View File

@@ -21,14 +21,16 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
ButtonModule,
@@ -39,6 +41,7 @@ import {
import { I18nPipe } from "@bitwarden/ui-common";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -86,6 +89,7 @@ export class SetInitialPasswordComponent implements OnInit {
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -101,6 +105,51 @@ export class SetInitialPasswordComponent implements OnInit {
this.initializing = false;
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
const accountEncryptionV2 = await this.configService.getFeatureFlag(
FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
);
if (accountEncryptionV2) {
await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
return;
}
await this.setInitialPassword(passwordInputResult);
break;
}
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
@@ -189,22 +238,39 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
private async setInitialPasswordJitMPUserV2Encryption(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password for SSO JIT master password encryption user.";
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertTruthy(passwordInputResult.salt, "salt", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
try {
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: passwordInputResult.newPasswordHint,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId as OrganizationId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
newPassword: passwordInputResult.newPassword,
salt: passwordInputResult.salt,
};
await this.setInitialPasswordService.initializePasswordJitPasswordUserV2Encryption(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
@@ -307,17 +373,4 @@ export class SetInitialPasswordComponent implements OnInit {
});
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
}

View File

@@ -1,5 +1,5 @@
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -61,6 +61,24 @@ export interface SetInitialPasswordTdeOffboardingCredentials {
newPasswordHint: string;
}
/**
* Credentials required to initialize a just-in-time (JIT) provisioned user with a master password.
*/
export interface InitializeJitPasswordCredentials {
/** Hint for the new master password */
newPasswordHint: string;
/** SSO identifier for the organization */
orgSsoIdentifier: string;
/** Organization ID */
orgId: OrganizationId;
/** Whether to auto-enroll the user in account recovery (reset password) */
resetPasswordAutoEnroll: boolean;
/** The new master password */
newPassword: string;
/** Master password salt (typically the user's email) */
salt: MasterPasswordSalt;
}
/**
* Handles setting an initial password for an existing authed user.
*
@@ -95,4 +113,14 @@ export abstract class SetInitialPasswordService {
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise<void>;
/**
* Initializes a JIT-provisioned user's cryptographic state and enrolls them in master password unlock.
* @param credentials The credentials needed to initialize the JIT password user
* @param userId The account userId
*/
abstract initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void>;
}

View File

@@ -108,7 +108,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
import { DefaultSendTokenService, SendTokenService } from "@bitwarden/common/auth/send-access";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
@@ -131,10 +131,10 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
TwoFactorApiService,
DefaultTwoFactorApiService,
TwoFactorService,
DefaultTwoFactorService,
TwoFactorApiService,
TwoFactorService,
} from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
@@ -208,8 +208,8 @@ import { PinService } from "@bitwarden/common/key-management/pin/pin.service.imp
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import {
SendPasswordService,
DefaultSendPasswordService,
SendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
@@ -387,12 +387,12 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
import {
DefaultVaultExportApiService,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
DefaultVaultExportApiService,
VaultExportApiService,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
VaultExportApiService,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
@@ -1583,6 +1583,7 @@ const safeProviders: SafeProvider[] = [
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

View File

@@ -1,4 +1,8 @@
// 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";
export {
AUTOFILL_NUDGE_SERVICE,
AUTO_CONFIRM_NUDGE_SERVICE,
} from "./services/nudge-injection-tokens";
export { AutoConfirmNudgeService } from "./services/custom-nudges-services";

View File

@@ -0,0 +1,204 @@
# Custom Nudge Services
This folder contains custom implementations of `SingleNudgeService` that provide specialized logic for determining when nudges should be shown or dismissed.
## Architecture Overview
### Core Components
- **`NudgesService`** (`../nudges.service.ts`) - The main service that components use to check nudge status and dismiss nudges
- **`SingleNudgeService`** - Interface that all nudge services implement
- **`DefaultSingleNudgeService`** - Base implementation that stores dismissed state in user state
- **Custom nudge services** - Specialized implementations with additional logic
### How It Works
1. Components call `NudgesService.showNudgeSpotlight$()` or `showNudgeBadge$()` with a `NudgeType`
2. `NudgesService` routes to the appropriate custom nudge service (or falls back to `DefaultSingleNudgeService`)
3. The custom service returns a `NudgeStatus` indicating if the badge/spotlight should be shown
4. Custom services can combine the persisted dismissed state with dynamic conditions (e.g., account age, vault contents)
### NudgeStatus
```typescript
type NudgeStatus = {
hasBadgeDismissed: boolean; // True if the badge indicator should be hidden
hasSpotlightDismissed: boolean; // True if the spotlight/callout should be hidden
};
```
## Service Categories
### Universal Services
These services work on **all clients** (browser, web, desktop) and use `@Injectable({ providedIn: "root" })`.
| Service | Purpose |
| --------------------------------- | ---------------------------------------------------------------------- |
| `NewAccountNudgeService` | Auto-dismisses after account is 30 days old |
| `NewItemNudgeService` | Checks cipher counts for "add first item" nudges |
| `HasItemsNudgeService` | Checks if vault has items |
| `EmptyVaultNudgeService` | Checks empty vault state |
| `AccountSecurityNudgeService` | Checks security settings (PIN, biometrics) |
| `VaultSettingsImportNudgeService` | Checks import status |
| `NoOpNudgeService` | Always returns dismissed (used as fallback for client specific nudges) |
### Client-Specific Services
These services require **platform-specific features** and must be explicitly registered in each client that supports them.
| Service | Clients | Requires |
| ----------------------------- | ------------ | -------------------------------------- |
| `AutoConfirmNudgeService` | Browser only | `AutomaticUserConfirmationService` |
| `BrowserAutofillNudgeService` | Browser only | `BrowserApi` (lives in `apps/browser`) |
## Adding a New Nudge Service
### Step 1: Determine if Universal or Client-Specific
**Universal** - If your service only depends on:
- `StateProvider`
- Services available in all clients (e.g., `CipherService`, `OrganizationService`)
**Client-Specific** - If your service depends on:
- Browser APIs (`BrowserApi`, autofill services)
- Services only available in certain clients
- Platform-specific features
### Step 2: Create the Service
#### For Universal Services
```typescript
// my-nudge.service.ts
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@Injectable({ providedIn: "root" })
export class MyNudgeService extends DefaultSingleNudgeService {
constructor(
stateProvider: StateProvider,
private myDependency: MyDependency, // Must be available in all clients
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId), // Gets persisted dismissed state
this.myDependency.someData$,
]).pipe(
map(([persistedStatus, data]) => {
// Return dismissed if user already dismissed OR your condition is met
const autoDismiss = /* your logic */;
return {
hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
};
}),
);
}
}
```
#### For Client-Specific Services
```typescript
// my-client-specific-nudge.service.ts
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@Injectable() // NO providedIn: "root"
export class MyClientSpecificNudgeService extends DefaultSingleNudgeService {
constructor(
stateProvider: StateProvider,
private clientSpecificService: ClientSpecificService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.clientSpecificService.someData$,
]).pipe(
map(([persistedStatus, data]) => {
const autoDismiss = /* your logic */;
return {
hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
};
}),
);
}
}
```
### Step 3: Add NudgeType
Add your nudge type to `NudgeType` in `../nudges.service.ts`:
```typescript
export const NudgeType = {
// ... existing types
MyNewNudge: "my-new-nudge",
} as const;
```
### Step 4: Register in NudgesService
#### For Universal Services
Add to `customNudgeServices` map in `../nudges.service.ts`:
```typescript
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
// ... existing
[NudgeType.MyNewNudge]: inject(MyNudgeService),
};
```
#### For Client-Specific Services
1. **Add injection token** in `../nudge-injection-tokens.ts`:
```typescript
export const MY_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>("MyNudgeService");
```
2. **Inject with optional** in `../nudges.service.ts`:
```typescript
private myNudgeService = inject(MY_NUDGE_SERVICE, { optional: true });
private customNudgeServices = {
// ... existing
[NudgeType.MyNewNudge]: this.myNudgeService ?? this.noOpNudgeService,
};
```
3. **Register in each supporting client** (e.g., `apps/browser/src/popup/services/services.module.ts`):
```typescript
import { MY_NUDGE_SERVICE } from "@bitwarden/angular/vault";
safeProvider({
provide: MY_NUDGE_SERVICE as SafeInjectionToken<SingleNudgeService>,
useClass: MyClientSpecificNudgeService,
deps: [StateProvider, ClientSpecificService],
}),
```

View File

@@ -1,15 +1,24 @@
import { inject, Injectable } from "@angular/core";
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeType, NudgeStatus } from "../nudges.service";
@Injectable({ providedIn: "root" })
/**
* Browser specific nudge service for auto-confirm nudge.
*/
@Injectable()
export class AutoConfirmNudgeService extends DefaultSingleNudgeService {
autoConfirmService = inject(AutomaticUserConfirmationService);
constructor(
stateProvider: StateProvider,
private autoConfirmService: AutomaticUserConfirmationService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([

View File

@@ -1,10 +1,11 @@
import { Injectable, inject } from "@angular/core";
import { Injectable } from "@angular/core";
import { Observable, combineLatest, from, map, of } from "rxjs";
import { catchError } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { StateProvider } from "@bitwarden/state";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@@ -18,8 +19,13 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
providedIn: "root",
})
export class NewAccountNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
constructor(
stateProvider: StateProvider,
private vaultProfileService: VaultProfileService,
private logService: LogService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from "@angular/core";
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -22,7 +22,11 @@ export interface SingleNudgeService {
providedIn: "root",
})
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected stateProvider: StateProvider;
constructor(stateProvider: StateProvider) {
this.stateProvider = stateProvider;
}
protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.stateProvider

View File

@@ -2,6 +2,25 @@ import { InjectionToken } from "@angular/core";
import { SingleNudgeService } from "./default-single-nudge.service";
/**
* Injection tokens for client specific nudge services.
*
* These services require platform-specific features and must be explicitly
* provided by each client that supports them. If not provided, NudgesService
* falls back to NoOpNudgeService.
*
* Client specific services should use constructor injection (not inject())
* to maintain safeProvider type safety.
*
* Universal services use @Injectable({ providedIn: "root" }) and can use inject().
*/
/** Browser: Requires BrowserApi */
export const AUTOFILL_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutofillNudgeService",
);
/** Browser: Requires AutomaticUserConfirmationService */
export const AUTO_CONFIRM_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutoConfirmNudgeService",
);

View File

@@ -12,11 +12,10 @@ import {
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
AutoConfirmNudgeService,
NoOpNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
import { AUTOFILL_NUDGE_SERVICE } from "./nudge-injection-tokens";
import { AUTOFILL_NUDGE_SERVICE, AUTO_CONFIRM_NUDGE_SERVICE } from "./nudge-injection-tokens";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
@@ -63,12 +62,21 @@ export class NudgesService {
// NoOp service that always returns dismissed
private noOpNudgeService = inject(NoOpNudgeService);
// Optional Browser-specific service provided via injection token (not all clients have autofill)
// Client specific services (optional, via injection tokens)
// These services require platform-specific features and fallback to NoOpNudgeService if not provided
private autofillNudgeService = inject(AUTOFILL_NUDGE_SERVICE, { optional: true });
private autoConfirmNudgeService = inject(AUTO_CONFIRM_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
*
* NOTE: If a custom nudge service requires client specific services/features:
* 1. The custom nudge service must be provided via injection token and marked as optional.
* 2. The custom nudge service must be manually registered with that token in the client(s).
*
* See the README.md in the custom-nudge-services folder for more details on adding custom nudges.
* @private
*/
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
@@ -84,7 +92,7 @@ export class NudgesService {
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
[NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
[NudgeType.AutoConfirmNudge]: this.autoConfirmNudgeService ?? this.noOpNudgeService,
};
/**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,11 +3,11 @@ import { svgIcon } from "../icon-service";
const BitwardenShield = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 32" fill="none">
<g clip-path="url(#bitwarden-shield-clip)">
<path class="tw-fill-text-alt2" d="M22.01 17.055V4.135h-9.063v22.954c1.605-.848 3.041-1.77 4.31-2.766 3.169-2.476 4.753-4.899 4.753-7.268Zm3.884-15.504v15.504a9.256 9.256 0 0 1-.677 3.442 12.828 12.828 0 0 1-1.68 3.029 18.708 18.708 0 0 1-2.386 2.574 27.808 27.808 0 0 1-2.56 2.08 32.251 32.251 0 0 1-2.448 1.564c-.85.49-1.453.824-1.81.999-.357.175-.644.31-.86.404-.162.08-.337.12-.526.12s-.364-.04-.526-.12a22.99 22.99 0 0 1-.86-.404c-.357-.175-.96-.508-1.81-1a32.242 32.242 0 0 1-2.448-1.564 27.796 27.796 0 0 1-2.56-2.08 18.706 18.706 0 0 1-2.386-2.573 12.828 12.828 0 0 1-1.68-3.029A9.256 9.256 0 0 1 0 17.055V1.551C0 1.2.128.898.384.642.641.386.944.26 1.294.26H24.6c.35 0 .654.127.91.383s.384.559.384.909Z"/>
<path class="tw-fill-fg-sidenav-text" d="M22.01 17.055V4.135h-9.063v22.954c1.605-.848 3.041-1.77 4.31-2.766 3.169-2.476 4.753-4.899 4.753-7.268Zm3.884-15.504v15.504a9.256 9.256 0 0 1-.677 3.442 12.828 12.828 0 0 1-1.68 3.029 18.708 18.708 0 0 1-2.386 2.574 27.808 27.808 0 0 1-2.56 2.08 32.251 32.251 0 0 1-2.448 1.564c-.85.49-1.453.824-1.81.999-.357.175-.644.31-.86.404-.162.08-.337.12-.526.12s-.364-.04-.526-.12a22.99 22.99 0 0 1-.86-.404c-.357-.175-.96-.508-1.81-1a32.242 32.242 0 0 1-2.448-1.564 27.796 27.796 0 0 1-2.56-2.08 18.706 18.706 0 0 1-2.386-2.573 12.828 12.828 0 0 1-1.68-3.029A9.256 9.256 0 0 1 0 17.055V1.551C0 1.2.128.898.384.642.641.386.944.26 1.294.26H24.6c.35 0 .654.127.91.383s.384.559.384.909Z"/>
</g>
<defs>
<clipPath id="bitwarden-shield-clip">
<path class="tw-fill-text-alt2" d="M0 0h26v32H0z"/>
<path class="tw-fill-fg-sidenav-text" d="M0 0h26v32H0z" />
</clipPath>
</defs>
</svg>

View File

@@ -121,13 +121,13 @@ export class CollectionAdminView extends CollectionView {
try {
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
} catch (e) {
view.name = "[error: cannot decrypt]";
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
e,
);
throw e;
}
view.assigned = collection.assigned;
view.readOnly = collection.readOnly;

View File

@@ -126,7 +126,14 @@ export class CollectionView implements View, ITreeNodeObject {
): Promise<CollectionView> {
const view = new CollectionView({ ...collection, name: "" });
view.name = await encryptService.decryptString(collection.name, key);
try {
view.name = await encryptService.decryptString(collection.name, key);
} catch (e) {
view.name = "[error: cannot decrypt]";
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
}
view.assigned = true;
view.externalId = collection.externalId;
view.readOnly = collection.readOnly;

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class ProviderUserConfirmRequest {
key: string;
protected key: string;
constructor(key: string) {
this.key = key;
}
}

View File

@@ -14,6 +14,7 @@ export enum FeatureFlag {
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -22,6 +23,7 @@ export enum FeatureFlag {
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
SSHAgentV2 = "ssh-agent-v2",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
@@ -38,13 +40,13 @@ export enum FeatureFlag {
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
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",
EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
/* Tools */
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
@@ -74,6 +76,7 @@ export enum FeatureFlag {
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
/* UIF */
RouterFocusManagement = "router-focus-management",
@@ -100,11 +103,13 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
@@ -144,13 +149,13 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
@@ -160,6 +165,7 @@ export const DefaultFeatureFlagValue = {
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,

View File

@@ -1,16 +1,8 @@
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { EncString } from "../models/enc-string";
export abstract class EncryptService {
/**
* A temporary init method to make the encrypt service listen to feature-flag changes.
* This will be removed once the feature flag has been rolled out.
*/
abstract init(configService: ConfigService): void;
/**
* Encrypts a string to an EncString
* @param plainValue - The value to encrypt

View File

@@ -1,9 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
@@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
private disableType0Decryption = false;
constructor(
protected cryptoFunctionService: CryptoFunctionService,
protected logService: LogService,
protected logMacFailures: boolean,
) {}
init(configService: ConfigService): void {
configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.setDisableType0Decryption(
newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true,
);
}
});
}
setDisableType0Decryption(disable: boolean): void {
this.disableType0Decryption = disable;
}
async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> {
if (plainValue == null) {
this.logService.warning(
@@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64
) {
if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}

View File

@@ -163,7 +163,7 @@ describe("EncryptService", () => {
describe("decryptString", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_string");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string");
const result = await encryptService.decryptString(encString, key);
expect(result).toEqual("decrypted_string");
expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith(
@@ -172,8 +172,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string");
await expect(encryptService.decryptString(encString, key)).rejects.toThrow(
@@ -185,7 +184,7 @@ describe("EncryptService", () => {
describe("decryptBytes", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_bytes");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes");
const result = await encryptService.decryptBytes(encString, key);
expect(result).toEqual(new Uint8Array(3));
expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith(
@@ -194,8 +193,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes");
await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow(
@@ -216,8 +214,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encBuffer = EncArrayBuffer.fromParts(
EncryptionType.AesCbc256_B64,
@@ -234,7 +231,10 @@ describe("EncryptService", () => {
describe("unwrapDecapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_decapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_decapsulation_key",
);
const result = await encryptService.unwrapDecapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(4));
expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith(
@@ -242,8 +242,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key");
await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow(
@@ -267,7 +266,10 @@ describe("EncryptService", () => {
describe("unwrapEncapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_encapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_encapsulation_key",
);
const result = await encryptService.unwrapEncapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(5));
expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith(
@@ -275,8 +277,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key");
await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow(
@@ -300,7 +301,10 @@ describe("EncryptService", () => {
describe("unwrapSymmetricKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_symmetric_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_symmetric_key",
);
const result = await encryptService.unwrapSymmetricKey(encString, key);
expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith(
@@ -308,8 +312,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key");
await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow(

View File

@@ -7,10 +7,10 @@ import { Icon } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Translation } from "../dialog";
import { LandingContentMaxWidthType } from "../landing-layout";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component";
import { AnonLayoutComponent } from "./anon-layout.component";
export interface AnonLayoutWrapperData {
/**
* The optional title of the page.
@@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData {
/**
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
*/
maxWidth?: AnonLayoutMaxWidth;
maxWidth?: LandingContentMaxWidthType;
/**
* Hide the card that wraps the default content. Defaults to false.
*/
@@ -59,7 +59,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
protected pageSubtitle?: string | null;
protected pageIcon: Icon | null = null;
protected showReadonlyHostname?: boolean | null;
protected maxWidth?: AnonLayoutMaxWidth | null;
protected maxWidth?: LandingContentMaxWidthType | null;
protected hideCardWrapper?: boolean | null;
protected hideBackgroundIllustration?: boolean | null;

View File

@@ -1,76 +1,26 @@
<main
class="tw-relative tw-flex tw-w-full tw-mx-auto tw-flex-col tw-bg-background-alt tw-p-5 tw-text-main"
[ngClass]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<div
[class]="
'tw-flex tw-justify-between tw-items-center tw-w-full' + (!hideLogo() ? ' tw-mb-12' : '')
"
>
@if (!hideLogo()) {
<a
[routerLink]="['/']"
class="tw-w-32 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
}
<div class="tw-ms-auto">
<ng-content select="[slot=header-actions]"></ng-content>
</div>
</div>
<bit-landing-layout [hideBackgroundIllustration]="hideBackgroundIllustration()">
<bit-landing-header [hideLogo]="hideLogo()">
<ng-content select="[slot=header-actions]"></ng-content>
</bit-landing-header>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
@let iconInput = icon();
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
*ngIf="iconInput !== null"
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="iconInput"></bit-icon>
</div>
@if (title()) {
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
}
@if (subtitle()) {
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
}
</div>
<div
class="tw-z-10 tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="maxWidthClass"
>
<bit-landing-content [maxWidth]="maxWidth()">
<bit-landing-hero [icon]="icon()" [title]="title()" [subtitle]="subtitle()"></bit-landing-hero>
@if (hideCardWrapper()) {
<div class="tw-mb-6 sm:tw-mb-10">
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</div>
} @else {
<bit-base-card
class="!tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
>
<bit-landing-card>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</bit-base-card>
</bit-landing-card>
}
<ng-content select="[slot=secondary]"></ng-content>
</div>
<div class="tw-flex tw-flex-col tw-items-center">
<ng-content select="[slot=secondary]"></ng-content>
</div>
</bit-landing-content>
@if (!hideFooter()) {
<footer class="tw-text-center tw-mt-4 sm:tw-mt-6">
<bit-landing-footer>
@if (showReadonlyHostname()) {
<div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div>
} @else {
@@ -81,22 +31,9 @@
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
}
</footer>
</bit-landing-footer>
}
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
</main>
</bit-landing-layout>
<ng-template #defaultContent>
<ng-content></ng-content>

View File

@@ -11,23 +11,17 @@ import {
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
BackgroundLeftIllustration,
BackgroundRightIllustration,
BitwardenLogo,
Icon,
} from "@bitwarden/assets/svg";
import { BitwardenLogo, Icon } from "@bitwarden/assets/svg";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseCardComponent } from "../card";
import { IconModule } from "../icon";
import { LandingContentMaxWidthType } from "../landing-layout";
import { LandingLayoutModule } from "../landing-layout/landing-layout.module";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -39,7 +33,7 @@ export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
TypographyModule,
SharedModule,
RouterModule,
BaseCardComponent,
LandingLayoutModule,
],
})
export class AnonLayoutComponent implements OnInit, OnChanges {
@@ -49,9 +43,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
return ["tw-h-full"];
}
readonly leftIllustration = BackgroundLeftIllustration;
readonly rightIllustration = BackgroundRightIllustration;
readonly title = input<string>();
readonly subtitle = input<string>();
readonly icon = model.required<Icon | null>();
@@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
*
* @default 'md'
*/
readonly maxWidth = model<AnonLayoutMaxWidth>("md");
readonly maxWidth = model<LandingContentMaxWidthType>("md");
protected logo = BitwardenLogo;
protected year: string;
@@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
protected hideYearAndVersion = false;
get maxWidthClass(): string {
const maxWidth = this.maxWidth();
switch (maxWidth) {
case "md":
return "tw-max-w-md";
case "lg":
return "tw-max-w-lg";
case "xl":
return "tw-max-w-xl";
case "2xl":
return "tw-max-w-2xl";
case "3xl":
return "tw-max-w-3xl";
case "4xl":
return "tw-max-w-4xl";
}
}
constructor(
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,

View File

@@ -2,7 +2,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -14,7 +13,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
@@ -42,7 +40,6 @@
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
linkType="primary"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
@@ -50,7 +47,7 @@
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
@@ -61,7 +58,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -73,7 +69,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>

View File

@@ -71,9 +71,9 @@ const styles: Record<IconButtonType, string[]> = {
primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
"nav-contrast": [
"!tw-text-alt2",
"!tw-text-fg-sidenav-text",
"hover:!tw-bg-hover-contrast",
"focus-visible:before:tw-ring-text-alt2",
"focus-visible:before:tw-ring-border-focus",
...focusRing,
],
};

View File

@@ -25,6 +25,7 @@ export * from "./icon";
export * from "./icon-tile";
export * from "./input";
export * from "./item";
export * from "./landing-layout";
export * from "./layout";
export * from "./link";
export * from "./menu";

View File

@@ -0,0 +1,7 @@
export * from "./landing-layout.component";
export * from "./landing-layout.module";
export * from "./landing-card.component";
export * from "./landing-content.component";
export * from "./landing-footer.component";
export * from "./landing-header.component";
export * from "./landing-hero.component";

View File

@@ -0,0 +1,5 @@
<bit-base-card
class="tw-z-[2] tw-relative !tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
>
<ng-content></ng-content>
</bit-base-card>

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { BaseCardComponent } from "../card";
/**
* Card component for landing pages that wraps content in a styled container.
*
* @remarks
* This component provides:
* - Card-based layout with consistent styling
* - Content projection for forms, text, or other content
* - Proper elevation and border styling
*
* Use this component inside `bit-landing-content` to wrap forms, content sections,
* or any content that should appear in a contained, elevated card.
*
* @example
* ```html
* <bit-landing-card>
* <form>
* <!-- Your form fields here -->
* </form>
* </bit-landing-card>
* ```
*/
@Component({
selector: "bit-landing-card",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [BaseCardComponent],
templateUrl: "./landing-card.component.html",
})
export class LandingCardComponent {}

View File

@@ -0,0 +1,8 @@
<div
class="tw-flex tw-flex-col tw-flex-1 tw-items-center tw-bg-background-alt tw-p-5 tw-pt-12 tw-text-main"
>
<div [class]="maxWidthClasses()">
<ng-content select="bit-landing-hero"></ng-content>
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const;
export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number];
/**
* Main content container for landing pages with configurable max-width constraints.
*
* @remarks
* This component provides:
* - Centered content area with alternative background color
* - Configurable maximum width to control content readability
* - Content projection slots for hero section and main content
* - Responsive padding and layout
*
* Use this component inside `bit-landing-layout` to wrap your main page content.
* Optionally include a `bit-landing-hero` as the first child for consistent hero section styling.
*
* @example
* ```html
* <bit-landing-content [maxWidth]="'xl'">
* <bit-landing-hero
* [icon]="lockIcon"
* [title]="'Welcome'"
* [subtitle]="'Get started with your account'"
* ></bit-landing-hero>
* <bit-landing-card>
* <!-- Your form or content here -->
* </bit-landing-card>
* </bit-landing-content>
* ```
*/
@Component({
selector: "bit-landing-content",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-content.component.html",
host: {
class: "tw-grow tw-flex tw-flex-col",
},
})
export class LandingContentComponent {
/**
* Max width of the landing layout container.
*
* @default "md"
*/
readonly maxWidth = input<LandingContentMaxWidthType>("md");
private readonly maxWidthClassMap: Record<LandingContentMaxWidthType, string> = {
md: "tw-max-w-md",
lg: "tw-max-w-lg",
xl: "tw-max-w-xl",
"2xl": "tw-max-w-2xl",
"3xl": "tw-max-w-3xl",
"4xl": "tw-max-w-4xl",
};
readonly maxWidthClasses = computed(() => {
const maxWidthClass = this.maxWidthClassMap[this.maxWidth()];
return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`;
});
}

View File

@@ -0,0 +1,3 @@
<footer class="tw-bg-background-alt tw-text-center tw-p-5 tw-pt-4 sm:tw-pt-6">
<ng-content></ng-content>
</footer>

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
/**
* Footer component for landing pages.
*
* @remarks
* This component provides:
* - Content projection for custom footer content (e.g., links, copyright, legal)
* - Consistent footer positioning at the bottom of the page
* - Proper z-index to appear above background illustrations
*
* Use this component inside `bit-landing-layout` as the last child to position it at the bottom.
*
* @example
* ```html
* <bit-landing-footer>
* <div class="tw-text-center tw-text-sm">
* <a routerLink="/privacy">Privacy</a>
* <span>© 2024 Bitwarden</span>
* </div>
* </bit-landing-footer>
* ```
*/
@Component({
selector: "bit-landing-footer",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-footer.component.html",
})
export class LandingFooterComponent {}

View File

@@ -0,0 +1,13 @@
<header class="tw-flex tw-w-full tw-bg-background-alt tw-px-5">
@if (!hideLogo()) {
<a
[routerLink]="['/']"
class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
}
<div class="[&:has(*)]:tw-ms-auto [&:has(*)]:tw-py-5">
<ng-content></ng-content>
</div>
</header>

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/assets/svg";
import { IconModule } from "../icon";
import { SharedModule } from "../shared";
/**
* Header component for landing pages with optional Bitwarden logo and header actions slot.
*
* @remarks
* This component provides:
* - Optional Bitwarden logo with link to home page (left-aligned)
* - Default content projection slot for header actions (right-aligned, auto-margin left)
* - Consistent header styling across landing pages
* - Responsive layout that adapts logo size
*
* Use this component inside `bit-landing-layout` as the first child to position it at the top.
* Content projected into this component will automatically align to the right side of the header.
*
* @example
* ```html
* <bit-landing-header [hideLogo]="false">
* <!-- Content here appears in the right-aligned actions slot -->
* <nav>
* <a routerLink="/login">Log in</a>
* <button type="button">Sign up</button>
* </nav>
* </bit-landing-header>
* ```
*/
@Component({
selector: "bit-landing-header",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-header.component.html",
imports: [RouterModule, IconModule, SharedModule],
})
export class LandingHeaderComponent {
readonly hideLogo = input<boolean>(false);
protected readonly logo = BitwardenLogo;
}

View File

@@ -0,0 +1,28 @@
@if (icon() || title() || subtitle()) {
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto">
@if (icon()) {
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="icon()"></bit-icon>
</div>
}
@if (title()) {
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
}
@if (subtitle()) {
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
}
</div>
}

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { Icon } from "@bitwarden/assets/svg";
import { IconModule } from "../icon";
import { TypographyModule } from "../typography";
/**
* Hero section component for landing pages featuring an optional icon, title, and subtitle.
*
* @remarks
* This component provides:
* - Optional icon display (e.g., feature icons, status icons)
* - Large title text with consistent typography
* - Subtitle text for additional context
* - Centered layout with proper spacing
*
* Use this component as the first child inside `bit-landing-content` to create a prominent
* hero section that introduces the page's purpose.
*
* @example
* ```html
* <bit-landing-hero
* [icon]="lockIcon"
* [title]="'Secure Your Passwords'"
* [subtitle]="'Create your account to get started'"
* ></bit-landing-hero>
* ```
*/
@Component({
selector: "bit-landing-hero",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-hero.component.html",
imports: [IconModule, TypographyModule],
})
export class LandingHeroComponent {
readonly icon = input<Icon | null>(null);
readonly title = input<string | undefined>();
readonly subtitle = input<string | undefined>();
}

View File

@@ -0,0 +1,25 @@
<div
class="tw-relative tw-flex tw-size-full tw-mx-auto tw-flex-col"
[class]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<ng-content select="bit-landing-header"></ng-content>
<main class="tw-relative tw-flex tw-flex-1 tw-size-full tw-mx-auto tw-flex-col">
<ng-content></ng-content>
</main>
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
<ng-content select="bit-landing-footer"></ng-content>
</div>

View File

@@ -0,0 +1,40 @@
import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core";
import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { IconModule } from "../icon";
/**
* Root layout component for landing pages providing a full-screen container with optional decorative background illustrations.
*
* @remarks
* This component serves as the outermost wrapper for landing pages and provides:
* - Full-screen layout that adapts to different client types (web, browser, desktop)
* - Optional decorative background illustrations in the bottom corners
* - Content projection slots for header, main content, and footer
*
* @example
* ```html
* <bit-landing-layout [hideBackgroundIllustration]="false">
* <bit-landing-header>...</bit-landing-header>
* <bit-landing-content>...</bit-landing-content>
* <bit-landing-footer>...</bit-landing-footer>
* </bit-landing-layout>
* ```
*/
@Component({
selector: "bit-landing-layout",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-layout.component.html",
imports: [IconModule],
})
export class LandingLayoutComponent {
readonly hideBackgroundIllustration = input<boolean>(false);
protected readonly leftIllustration = BackgroundLeftIllustration;
protected readonly rightIllustration = BackgroundRightIllustration;
private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService);
protected readonly clientType = this.platformUtilsService.getClientType();
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from "@angular/core";
import { LandingCardComponent } from "./landing-card.component";
import { LandingContentComponent } from "./landing-content.component";
import { LandingFooterComponent } from "./landing-footer.component";
import { LandingHeaderComponent } from "./landing-header.component";
import { LandingHeroComponent } from "./landing-hero.component";
import { LandingLayoutComponent } from "./landing-layout.component";
@NgModule({
imports: [
LandingLayoutComponent,
LandingHeaderComponent,
LandingHeroComponent,
LandingFooterComponent,
LandingContentComponent,
LandingCardComponent,
],
exports: [
LandingLayoutComponent,
LandingHeaderComponent,
LandingHeroComponent,
LandingFooterComponent,
LandingContentComponent,
LandingCardComponent,
],
})
export class LandingLayoutModule {}

View File

@@ -0,0 +1,162 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule } from "../button";
import { LandingLayoutComponent } from "./landing-layout.component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getClientType = () => ClientType.Web;
}
type StoryArgs = LandingLayoutComponent & {
contentLength: "normal" | "long" | "thin";
includeHeader: boolean;
includeFooter: boolean;
};
export default {
title: "Component Library/Landing Layout",
component: LandingLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
],
}),
],
render: (args) => {
return {
props: args,
template: /*html*/ `
<bit-landing-layout
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
@if (includeHeader) {
<bit-landing-header>
<div class="tw-p-4">
<div class="tw-flex tw-items-center tw-gap-4">
<div class="tw-text-xl tw-font-semibold">Header Content</div>
</div>
</div>
</bit-landing-header>
}
<div>
@switch (contentLength) {
@case ('thin') {
<div class="tw-text-center tw-p-8">
<div class="tw-font-medium">Thin Content</div>
</div>
}
@case ('long') {
<div class="tw-p-8">
<div class="tw-font-medium tw-mb-4">Long Content</div>
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
</div>
}
@default {
<div class="tw-p-8">
<div class="tw-font-medium tw-mb-4">Normal Content</div>
<div>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</div>
</div>
}
}
</div>
@if (includeFooter) {
<bit-landing-footer>
<div class="tw-text-center tw-text-sm tw-text-muted">
<div>Footer Content</div>
</div>
</bit-landing-footer>
}
</bit-landing-layout>
`,
};
},
argTypes: {
hideBackgroundIllustration: { control: "boolean" },
contentLength: {
control: "radio",
options: ["normal", "long", "thin"],
},
includeHeader: { control: "boolean" },
includeFooter: { control: "boolean" },
},
args: {
hideBackgroundIllustration: false,
contentLength: "normal",
includeHeader: false,
includeFooter: false,
},
} satisfies Meta<StoryArgs>;
type Story = StoryObj<StoryArgs>;
export const Default: Story = {
args: {
contentLength: "normal",
},
};
export const WithHeader: Story = {
args: {
includeHeader: true,
},
};
export const WithFooter: Story = {
args: {
includeFooter: true,
},
};
export const WithHeaderAndFooter: Story = {
args: {
includeHeader: true,
includeFooter: true,
},
};
export const LongContent: Story = {
args: {
contentLength: "long",
includeHeader: true,
includeFooter: true,
},
};
export const ThinContent: Story = {
args: {
contentLength: "thin",
includeHeader: true,
includeFooter: true,
},
};
export const NoBackgroundIllustration: Story = {
args: {
hideBackgroundIllustration: true,
includeHeader: true,
includeFooter: true,
},
};
export const MinimalState: Story = {
args: {
contentLength: "thin",
hideBackgroundIllustration: true,
includeHeader: false,
includeFooter: false,
},
};

View File

@@ -1,5 +1,5 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-size-full">
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
<div class="tw-flex tw-size-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
@@ -24,6 +24,7 @@
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
[class.tw-rounded-tl-2xl]="rounded()"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>

View File

@@ -1,7 +1,7 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
@@ -38,6 +38,12 @@ export class LayoutComponent {
protected drawerPortal = inject(DrawerService).portal;
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
/**
* Rounded top left corner for the main content area
*/
readonly rounded = input(false, { transform: booleanAttribute });
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}

View File

@@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { LayoutComponent } from "./layout.component";
import { mockLayoutI18n } from "./mocks";
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
export default {
title: "Component Library/Layout",
component: LayoutComponent,
@@ -63,7 +65,7 @@ export const WithContent: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<bit-layout>
<bit-layout ${formatArgsForCodeSnippet<LayoutComponent>(args)}>
<bit-side-nav>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
@@ -111,3 +113,10 @@ export const Secondary: Story = {
`,
}),
};
export const Rounded: Story = {
...WithContent,
args: {
rounded: true,
},
};

View File

@@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f
import { AriaDisableDirective } from "../a11y";
import { ariaDisableElement } from "../utils";
export type LinkType = "primary" | "secondary" | "contrast" | "light";
export const LinkTypes = [
"primary",
"secondary",
"contrast",
"light",
"default",
"subtle",
"success",
"warning",
"danger",
] as const;
export type LinkType = (typeof LinkTypes)[number];
const linkStyles: Record<LinkType, string[]> = {
primary: [
"!tw-text-primary-600",
"hover:!tw-text-primary-700",
"focus-visible:before:tw-ring-primary-600",
],
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
contrast: [
"!tw-text-contrast",
"hover:!tw-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"tw-text-fg-contrast",
"hover:tw-text-fg-contrast",
"focus-visible:before:tw-ring-fg-contrast",
],
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
};
const commonStyles = [
@@ -32,16 +45,18 @@ const commonStyles = [
"tw-rounded",
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-secondary-300",
"disabled:hover:!tw-text-secondary-300",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
@@ -63,14 +78,14 @@ const commonStyles = [
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
"aria-disabled:tw-pointer-events-none",
"aria-disabled:!tw-text-secondary-300",
"aria-disabled:hover:!tw-text-secondary-300",
"aria-disabled:!tw-text-fg-disabled",
"aria-disabled:hover:!tw-text-fg-disabled",
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("primary");
readonly linkType = input<LinkType>("default");
}
/**

View File

@@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components";
You can use one of the following variants by providing it as the `linkType` input:
- `primary` - most common, uses brand color
- `secondary` - matches the main text color
- @deprecated `primary` => use `default` instead
- @deprecated `secondary` => use `subtle` instead
- `default` - most common, uses brand color
- `subtle` - matches the main text color
- `contrast` - for high contrast against a dark background (or a light background in dark mode)
- `light` - always a light color, even in dark mode
- `warning` - used in association with warning callouts/banners
- `success` - used in association with success callouts/banners
- `danger` - used in association with danger callouts/banners
## Sizes

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
@@ -14,7 +14,7 @@ export default {
],
argTypes: {
linkType: {
options: ["primary", "secondary", "contrast"],
options: LinkTypes.map((type) => type),
control: { type: "radio" },
},
},
@@ -30,48 +30,153 @@ type Story = StoryObj<ButtonLinkDirective>;
export const Default: Story = {
render: (args) => ({
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
`,
}),
args: {
linkType: "primary",
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const AllVariations: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const InteractionStates: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const Buttons: Story = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">Button</button>
</div>
@@ -100,9 +205,17 @@ export const Buttons: Story = {
export const Anchors: StoryObj<AnchorLinkDirective> = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">Anchor</a>
</div>
@@ -138,18 +251,15 @@ export const Inline: Story = {
</span>
`,
}),
args: {
linkType: "primary",
},
};
export const Disabled: Story = {
export const Inactive: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
</div>
`,

View File

@@ -19,7 +19,7 @@
<ng-template #button>
<button
type="button"
class="tw-ms-auto"
class="tw-ms-auto tw-text-fg-sidenav-text"
[ngClass]="{
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
}"

View File

@@ -4,15 +4,15 @@
<div
[style.padding-inline-start]="navItemIndentationPadding()"
class="tw-relative tw-rounded-md tw-h-10"
[class.tw-bg-background-alt4]="showActiveStyles"
[class.tw-bg-background-alt3]="!showActiveStyles"
[class.hover:tw-bg-hover-contrast]="!showActiveStyles"
[class.tw-bg-bg-sidenav-active-item]="showActiveStyles"
[class.tw-bg-bg-sidenav-background]="!showActiveStyles"
[class.hover:tw-bg-bg-sidenav-item-hover]="!showActiveStyles"
[class]="fvwStyles$ | async"
>
<div class="tw-relative tw-flex tw-items-center tw-h-full">
@if (open) {
<div
class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_+_4px)] [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_+_4px)] [&>*:focus-visible::before]:!tw-ring-border-focus [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
>
<ng-content select="[slot=start]"></ng-content>
</div>
@@ -31,7 +31,7 @@
>
@if (icon()) {
<i
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text {{ icon() }}"
[attr.aria-hidden]="open"
[attr.aria-label]="text()"
></i>
@@ -47,7 +47,7 @@
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
<!-- The following `class` field should match the `#isButton` class field below -->
<a
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
data-fvw
[routerLink]="route()"
@@ -68,7 +68,7 @@
<!-- Class field should match `#isAnchor` class field above -->
<button
type="button"
class="tw-size-full tw-px-4 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
data-fvw
(click)="mainContentClicked.emit()"
@@ -79,7 +79,7 @@
@if (open) {
<div
class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-border-focus [&>*:hover]:!tw-border-border-focus [&>*]:tw-text-fg-sidenav-text empty:tw-hidden"
>
<ng-content select="[slot=end]"></ng-content>
</div>

View File

@@ -90,7 +90,7 @@ export class NavItemComponent extends NavBaseComponent {
protected focusVisibleWithin$ = new BehaviorSubject(false);
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
map((value) =>
value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-text-alt2" : "",
value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" : "",
),
);
@HostListener("focusin", ["$event.target"])

View File

@@ -8,7 +8,7 @@
<!-- absolutely position the link svg to avoid shifting layout when sidenav is closed -->
<a
[routerLink]="route()"
class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]"
class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-bg-sidenav tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-border-focus hover:tw-bg-bg-sidenav-item-hover tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]"
[ngClass]="{
'!tw-h-[55px] [&_svg]:!tw-w-[26px]': !sideNavService.open,
}"

View File

@@ -8,14 +8,14 @@
<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"
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-bg-sidenav tw-text-fg-sidenav-text 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)',
'--color-sidenav-text': 'var(--color-admin-sidenav-text)',
'--color-sidenav-background': 'var(--color-admin-sidenav-background)',
'--color-sidenav-active-item': 'var(--color-admin-sidenav-active-item)',
'--color-sidenav-item-hover': 'var(--color-admin-sidenav-item-hover)',
}
"
[cdkTrapFocus]="data.isOverlay"
@@ -27,7 +27,7 @@
<!-- 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"
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full"
>
<bit-nav-divider></bit-nav-divider>
@if (data.open) {

View File

@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
A random password
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -112,13 +112,12 @@ class KitchenSinkDialogComponent {
<div class="tw-my-6">
<h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1>
<a bitLink linkType="primary" href="#">This is a link</a>
<a bitLink href="#">This is a link</a>
<p bitTypography="body1" class="tw-inline">
&nbsp;and this is a link button popover trigger:&nbsp;
</p>
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -353,6 +353,19 @@
/* Focus Border */
--color-border-focus: var(--color-black);
/**========================================
* SIDENAV BACKGROUND COLORS (Light mode)
* ======================================== */
--color-sidenav-background: var(--color-brand-800);
--color-sidenav-text: var(--color-white);
--color-sidenav-active-item: var(--color-brand-900);
--color-sidenav-item-hover: var(--color-brand-900);
--color-admin-sidenav-background: var(--color-gray-100);
--color-admin-sidenav-text: var(--color-gray-900);
--color-admin-sidenav-active-item: var(--color-gray-300);
--color-admin-sidenav-item-hover: var(--color-gray-300);
}
.theme_light {
@@ -542,6 +555,19 @@
/* Focus Border */
--color-border-focus: var(--color-white);
/**========================================
* SIDENAV BACKGROUND COLORS (Dark mode)
* ======================================== */
--color-sidenav-background: var(--color-gray-800);
--color-sidenav-text: var(--color-white);
--color-sidenav-active-item: var(--color-gray-900);
--color-sidenav-item-hover: var(--color-gray-900);
--color-admin-sidenav-background: var(--color-gray-800);
--color-admin-sidenav-text: var(--color-white);
--color-admin-sidenav-active-item: var(--color-gray-900);
--color-admin-sidenav-item-hover: var(--color-gray-900);
}
@layer components {

View File

@@ -72,11 +72,11 @@ module.exports = {
code: rgba("--color-text-code"),
},
background: {
DEFAULT: rgba("--color-background"),
alt: rgba("--color-background-alt"),
alt2: rgba("--color-background-alt2"),
alt3: rgba("--color-background-alt3"),
alt4: rgba("--color-background-alt4"),
DEFAULT: "var(--color-bg-primary)",
alt: "var(--color-bg-tertiary)",
alt2: "var(--color-bg-brand)",
alt3: "var(--color-bg-brand-strong)",
alt4: "var(--color-brand-950)",
},
bg: {
white: "var(--color-bg-white)",
@@ -117,6 +117,9 @@ module.exports = {
"accent-tertiary": "var(--color-bg-accent-tertiary)",
hover: "var(--color-bg-hover)",
overlay: "var(--color-bg-overlay)",
sidenav: "var(--color-sidenav-background)",
"sidenav-active-item": "var(--color-sidenav-active-item)",
"sidenav-item-hover": "var(--color-sidenav-item-hover)",
},
hover: {
default: "var(--color-hover-default)",
@@ -159,6 +162,7 @@ module.exports = {
"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)",
"sidenav-text": "var(--color-sidenav-text)",
},
border: {
muted: "var(--color-border-muted)",
@@ -253,6 +257,7 @@ module.exports = {
"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)",
"fg-sidenav-text": "var(--color-sidenav-text)",
}),
borderColor: ({ theme }) => ({
...theme("colors"),

View File

@@ -195,6 +195,8 @@ export class ImportChromeComponent implements OnInit, OnDestroy {
return "Brave";
} else if (format === "vivaldicsv") {
return "Vivaldi";
} else if (format === "arccsv") {
return "Arc";
}
return "Chrome";
}

View File

@@ -0,0 +1,139 @@
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { ArcCsvImporter } from "./arc-csv-importer";
import { data as missingNameAndUrlData } from "./spec-data/arc-csv/missing-name-and-url-data.csv";
import { data as missingNameWithUrlData } from "./spec-data/arc-csv/missing-name-with-url-data.csv";
import { data as passwordWithNoteData } from "./spec-data/arc-csv/password-with-note-data.csv";
import { data as simplePasswordData } from "./spec-data/arc-csv/simple-password-data.csv";
import { data as subdomainData } from "./spec-data/arc-csv/subdomain-data.csv";
import { data as urlWithWwwData } from "./spec-data/arc-csv/url-with-www-data.csv";
const CipherData = [
{
title: "should parse password",
csv: simplePasswordData,
expected: Object.assign(new CipherView(), {
name: "example.com",
login: Object.assign(new LoginView(), {
username: "user@example.com",
password: "password123",
uris: [
Object.assign(new LoginUriView(), {
uri: "https://example.com/",
}),
],
}),
notes: null,
type: 1,
}),
},
{
title: "should parse password with note",
csv: passwordWithNoteData,
expected: Object.assign(new CipherView(), {
name: "example.com",
login: Object.assign(new LoginView(), {
username: "user@example.com",
password: "password123",
uris: [
Object.assign(new LoginUriView(), {
uri: "https://example.com/",
}),
],
}),
notes: "This is a test note",
type: 1,
}),
},
{
title: "should strip www. prefix from name",
csv: urlWithWwwData,
expected: Object.assign(new CipherView(), {
name: "example.com",
login: Object.assign(new LoginView(), {
username: "user@example.com",
password: "password123",
uris: [
Object.assign(new LoginUriView(), {
uri: "https://www.example.com/",
}),
],
}),
notes: null,
type: 1,
}),
},
{
title: "should extract name from URL when name is missing",
csv: missingNameWithUrlData,
expected: Object.assign(new CipherView(), {
name: "example.com",
login: Object.assign(new LoginView(), {
username: "user@example.com",
password: "password123",
uris: [
Object.assign(new LoginUriView(), {
uri: "https://example.com/login",
}),
],
}),
notes: null,
type: 1,
}),
},
{
title: "should use -- as name when both name and URL are missing",
csv: missingNameAndUrlData,
expected: Object.assign(new CipherView(), {
name: "--",
login: Object.assign(new LoginView(), {
username: null,
password: "password123",
uris: null,
}),
notes: null,
type: 1,
}),
},
{
title: "should preserve subdomain in name",
csv: subdomainData,
expected: Object.assign(new CipherView(), {
name: "login.example.com",
login: Object.assign(new LoginView(), {
username: "user@example.com",
password: "password123",
uris: [
Object.assign(new LoginUriView(), {
uri: "https://login.example.com/auth",
}),
],
}),
notes: null,
type: 1,
}),
},
];
describe("Arc CSV Importer", () => {
CipherData.forEach((data) => {
it(data.title, async () => {
jest.useFakeTimers().setSystemTime(data.expected.creationDate);
const importer = new ArcCsvImporter();
const result = await importer.parse(data.csv);
expect(result != null).toBe(true);
expect(result.ciphers.length).toBeGreaterThan(0);
const cipher = result.ciphers.shift();
let property: keyof typeof data.expected;
for (property in data.expected) {
if (Object.prototype.hasOwnProperty.call(data.expected, property)) {
expect(Object.prototype.hasOwnProperty.call(cipher, property)).toBe(true);
expect(cipher[property]).toEqual(data.expected[property]);
}
}
});
});
});

View File

@@ -0,0 +1,30 @@
import { ImportResult } from "../models/import-result";
import { BaseImporter } from "./base-importer";
import { Importer } from "./importer";
export class ArcCsvImporter extends BaseImporter implements Importer {
parse(data: string): Promise<ImportResult> {
const result = new ImportResult();
const results = this.parseCsv(data, true);
if (results == null) {
result.success = false;
return Promise.resolve(result);
}
results.forEach((value) => {
const cipher = this.initLoginCipher();
const url = this.getValueOrDefault(value.url);
cipher.name = this.getValueOrDefault(this.nameFromUrl(url) ?? "", "--");
cipher.login.username = this.getValueOrDefault(value.username);
cipher.login.password = this.getValueOrDefault(value.password);
cipher.login.uris = this.makeUriArray(value.url);
cipher.notes = this.getValueOrDefault(value.note);
this.cleanupCipher(cipher);
result.ciphers.push(cipher);
});
result.success = true;
return Promise.resolve(result);
}
}

View File

@@ -1,3 +1,4 @@
export { ArcCsvImporter } from "./arc-csv-importer";
export { AscendoCsvImporter } from "./ascendo-csv-importer";
export { AvastCsvImporter, AvastJsonImporter } from "./avast";
export { AviraCsvImporter } from "./avira-csv-importer";

View File

@@ -0,0 +1,2 @@
export const data = `name,url,username,password,note
,,,password123,`;

View File

@@ -0,0 +1,2 @@
export const data = `name,url,username,password,note
,https://example.com/login,user@example.com,password123,`;

View File

@@ -0,0 +1,2 @@
export const data = `name,url,username,password,note
example.com,https://example.com/,user@example.com,password123,This is a test note`;

View File

@@ -0,0 +1,2 @@
export const data = `name,url,username,password,note
example.com,https://example.com/,user@example.com,password123,`;

View File

@@ -0,0 +1,2 @@
export const data = `name,url,username,password,note
login.example.com,https://login.example.com/auth,user@example.com,password123,`;

View File

@@ -0,0 +1,2 @@
export const data = `name,url,username,password,note
www.example.com,https://www.example.com/,user@example.com,password123,`;

View File

@@ -46,6 +46,7 @@ export const regularImportOptions = [
{ id: "ascendocsv", name: "Ascendo DataVault (csv)" },
{ id: "meldiumcsv", name: "Meldium (csv)" },
{ id: "passkeepcsv", name: "PassKeep (csv)" },
{ id: "arccsv", name: "Arc" },
{ id: "edgecsv", name: "Edge" },
{ id: "operacsv", name: "Opera" },
{ id: "vivaldicsv", name: "Vivaldi" },

View File

@@ -31,6 +31,7 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res
import { KeyService } from "@bitwarden/key-management";
import {
ArcCsvImporter,
AscendoCsvImporter,
AvastCsvImporter,
AvastJsonImporter,
@@ -256,6 +257,8 @@ export class ImportService implements ImportServiceAbstraction {
return new PadlockCsvImporter();
case "keepass2xml":
return new KeePass2XmlImporter();
case "arccsv":
return new ArcCsvImporter();
case "edgecsv":
case "chromecsv":
case "operacsv":

View File

@@ -280,8 +280,7 @@ export abstract class KeyService {
* encrypted private key at all.
*
* @param userId The user id of the user to get the data for.
* @returns An observable stream of the decrypted private key or null.
* @throws Error when decryption of the encrypted private key fails.
* @returns An observable stream of the decrypted private key or null if the private key is not present or fails to decrypt
*/
abstract userPrivateKey$(userId: UserId): Observable<UserPrivateKey | null>;

View File

@@ -437,14 +437,13 @@ describe("keyService", () => {
);
});
it("throws an error if unwrapping encrypted private key fails", async () => {
it("emits null if unwrapping encrypted private key fails", async () => {
encryptService.unwrapDecapsulationKey.mockImplementationOnce(() => {
throw new Error("Unwrapping failed");
});
await expect(firstValueFrom(keyService.userPrivateKey$(mockUserId))).rejects.toThrow(
"Unwrapping failed",
);
const result = await firstValueFrom(keyService.userPrivateKey$(mockUserId));
expect(result).toBeNull();
});
it("returns null if user key is not set", async () => {

View File

@@ -791,7 +791,10 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$;
}
private userPrivateKeyHelper$(userId: UserId) {
private userPrivateKeyHelper$(userId: UserId): Observable<{
userKey: UserKey;
userPrivateKey: UserPrivateKey | null;
} | null> {
const userKey$ = this.userKey$(userId);
return userKey$.pipe(
switchMap((userKey) => {
@@ -801,18 +804,20 @@ export class DefaultKeyService implements KeyServiceAbstraction {
return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$.pipe(
switchMap(async (encryptedPrivateKey) => {
try {
return await this.decryptPrivateKey(encryptedPrivateKey, userKey);
} catch (e) {
this.logService.error("Failed to decrypt private key for user ", userId, e);
throw e;
}
return await this.decryptPrivateKey(encryptedPrivateKey, userKey);
}),
// Combine outerscope info with user private key
map((userPrivateKey) => ({
userKey,
userPrivateKey,
})),
catchError((err: unknown) => {
this.logService.error(`Failed to decrypt private key for user ${userId}`);
return of({
userKey,
userPrivateKey: null,
});
}),
);
}),
);

View File

@@ -1,17 +1,8 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
output,
} from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import {
ButtonModule,
@@ -57,8 +48,6 @@ export class SendListComponent {
protected readonly noResultsIcon = NoResults;
protected readonly sendListState = SendListState;
private i18nService = inject(I18nService);
readonly sends = input.required<SendView[]>();
readonly loading = input<boolean>(false);
readonly disableSend = input<boolean>(false);
@@ -70,7 +59,7 @@ export class SendListComponent {
);
protected readonly noSearchResults = computed(
() => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0),
() => this.showSearchBar() && this.sends().length === 0,
);
// Reusable data source instance - updated reactively when sends change

View File

@@ -8,7 +8,7 @@
id="newItemDropdown"
[appA11yTitle]="'new' | i18n"
>
<i class="bwi bwi-plus" aria-hidden="true"></i>
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">

View File

@@ -120,5 +120,19 @@ describe("ArchiveCipherUtilitiesService", () => {
message: "errorOccurred",
});
});
it("calls password reprompt check when unarchiving", async () => {
await service.unarchiveCipher(mockCipher);
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(mockCipher);
});
it("returns early when password reprompt fails on unarchive", async () => {
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
await service.unarchiveCipher(mockCipher);
expect(cipherArchiveService.unarchiveWithServer).not.toHaveBeenCalled();
});
});
});

View File

@@ -41,7 +41,8 @@ export class ArchiveCipherUtilitiesService {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "archiveItem" },
content: { key: "archiveItemConfirmDesc" },
content: { key: "archiveItemDialogContent" },
acceptButtonText: { key: "archiveVerb" },
type: "info",
});
@@ -73,7 +74,14 @@ export class ArchiveCipherUtilitiesService {
* @param cipher The cipher to unarchive
* @returns The unarchived cipher on success, or undefined on failure
*/
async unarchiveCipher(cipher: CipherView) {
async unarchiveCipher(cipher: CipherView, skipReprompt = false) {
if (!skipReprompt) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
}
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
try {
const cipherResponse = await this.cipherArchiveService.unarchiveWithServer(

View File

@@ -2,7 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of, Subject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -42,6 +42,7 @@ describe("DefaultVaultItemsTransferService", () => {
let mockToastService: MockProxy<ToastService>;
let mockEventCollectionService: MockProxy<EventCollectionService>;
let mockConfigService: MockProxy<ConfigService>;
let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>;
const userId = "user-id" as UserId;
const organizationId = "org-id" as OrganizationId;
@@ -77,6 +78,7 @@ describe("DefaultVaultItemsTransferService", () => {
mockToastService = mock<ToastService>();
mockEventCollectionService = mock<EventCollectionService>();
mockConfigService = mock<ConfigService>();
mockOrganizationUserApiService = mock<OrganizationUserApiService>();
mockI18nService.t.mockImplementation((key) => key);
transferInProgressValues = [];
@@ -92,6 +94,7 @@ describe("DefaultVaultItemsTransferService", () => {
mockToastService,
mockEventCollectionService,
mockConfigService,
mockOrganizationUserApiService,
);
});
@@ -632,9 +635,15 @@ describe("DefaultVaultItemsTransferService", () => {
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "leftOrganization",
});
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});

View File

@@ -10,7 +10,7 @@ import {
} from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
import { CollectionService, OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -53,6 +53,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
private toastService: ToastService,
private eventCollectionService: EventCollectionService,
private configService: ConfigService,
private organizationUserApiService: OrganizationUserApiService,
) {}
private _transferInProgressSubject = new BehaviorSubject(false);
@@ -162,7 +163,12 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
);
if (!userAcceptedTransfer) {
// TODO: Revoke user from organization if they decline migration and show toast PM-29465
await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("leftOrganization"),
});
await this.eventCollectionService.collect(
EventType.Organization_ItemOrganization_Declined,