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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
204
libs/angular/src/vault/services/custom-nudges-services/README.md
Normal file
204
libs/angular/src/vault/services/custom-nudges-services/README.md
Normal 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],
|
||||
}),
|
||||
```
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">© {{ 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
7
libs/components/src/landing-layout/index.ts
Normal file
7
libs/components/src/landing-layout/index.ts
Normal 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";
|
||||
@@ -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>
|
||||
33
libs/components/src/landing-layout/landing-card.component.ts
Normal file
33
libs/components/src/landing-layout/landing-card.component.ts
Normal 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 {}
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
40
libs/components/src/landing-layout/landing-hero.component.ts
Normal file
40
libs/components/src/landing-layout/landing-hero.component.ts
Normal 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>();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
28
libs/components/src/landing-layout/landing-layout.module.ts
Normal file
28
libs/components/src/landing-layout/landing-layout.module.ts
Normal 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 {}
|
||||
162
libs/components/src/landing-layout/landing-layout.stories.ts
Normal file
162
libs/components/src/landing-layout/landing-layout.stories.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -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(),
|
||||
}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
}"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
A random password
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -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">
|
||||
and this is a link button popover trigger:
|
||||
</p>
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
139
libs/importer/src/importers/arc-csv-importer.spec.ts
Normal file
139
libs/importer/src/importers/arc-csv-importer.spec.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
30
libs/importer/src/importers/arc-csv-importer.ts
Normal file
30
libs/importer/src/importers/arc-csv-importer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const data = `name,url,username,password,note
|
||||
,,,password123,`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const data = `name,url,username,password,note
|
||||
,https://example.com/login,user@example.com,password123,`;
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const data = `name,url,username,password,note
|
||||
example.com,https://example.com/,user@example.com,password123,`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const data = `name,url,username,password,note
|
||||
login.example.com,https://login.example.com/auth,user@example.com,password123,`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const data = `name,url,username,password,note
|
||||
www.example.com,https://www.example.com/,user@example.com,password123,`;
|
||||
@@ -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" },
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user