mirror of
https://github.com/bitwarden/browser
synced 2026-02-27 10:03:23 +00:00
Merge branch 'main' of https://github.com/bitwarden/clients into vault/pm-30304/cipher-sdk-get
This commit is contained in:
@@ -896,7 +896,7 @@ const safeProviders: SafeProvider[] = [
|
||||
FolderApiServiceAbstraction,
|
||||
InternalOrganizationServiceAbstraction,
|
||||
SendApiServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
AvatarServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
BillingAccountProfileStateService,
|
||||
|
||||
@@ -194,12 +194,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
||||
return this.searchService.searchCiphers(
|
||||
userId,
|
||||
searchText,
|
||||
[
|
||||
filter,
|
||||
this.deletedFilter,
|
||||
...(this.deleted ? [] : [this.archivedFilter]),
|
||||
restrictedTypeFilter,
|
||||
],
|
||||
[filter, restrictedTypeFilter],
|
||||
allCiphers,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -54,6 +54,12 @@ export class VaultFilter {
|
||||
cipherPassesFilter =
|
||||
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
|
||||
if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) {
|
||||
cipherPassesFilter =
|
||||
!CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
|
||||
if (this.cipherType != null && cipherPassesFilter) {
|
||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
WebAuthnPrfOption: {
|
||||
EncryptedPrivateKey: mockEncPrfPrivateKey,
|
||||
EncryptedUserKey: mockEncUserKey,
|
||||
CredentialId: "mockCredentialId",
|
||||
Transports: ["usb", "nfc"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
|
||||
|
||||
if (userDecryptionOptions?.webAuthnPrfOption) {
|
||||
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
|
||||
|
||||
const credentials = this.cache.value.credentials;
|
||||
|
||||
// confirm we still have the prf key
|
||||
if (!credentials.prfKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption;
|
||||
|
||||
// decrypt prf encrypted private key
|
||||
const privateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||
webAuthnPrfOption.encryptedPrivateKey,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Jsonify } from "type-fest";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
|
||||
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
|
||||
import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
|
||||
|
||||
/**
|
||||
* Key Connector decryption options. Intended to be sent to the client for use after authentication.
|
||||
@@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
*/
|
||||
/**
|
||||
* WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
*/
|
||||
export class WebAuthnPrfUserDecryptionOption {
|
||||
/** The encrypted private key that can be decrypted with the PRF key. */
|
||||
encryptedPrivateKey: string;
|
||||
/** The encrypted user key that can be decrypted with the private key. */
|
||||
encryptedUserKey: string;
|
||||
/** The credential ID for this WebAuthn PRF credential. */
|
||||
credentialId: string;
|
||||
/** The transports supported by this credential. */
|
||||
transports: string[];
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object.
|
||||
* @param response The WebAuthn PRF user decryption option response object.
|
||||
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish.
|
||||
*/
|
||||
static fromResponse(
|
||||
response: WebAuthnPrfDecryptionOptionResponse,
|
||||
): WebAuthnPrfUserDecryptionOption | undefined {
|
||||
if (response == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!response.encryptedPrivateKey || !response.encryptedUserKey) {
|
||||
return undefined;
|
||||
}
|
||||
const options = new WebAuthnPrfUserDecryptionOption();
|
||||
options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString;
|
||||
options.encryptedUserKey = response.encryptedUserKey.encryptedString;
|
||||
options.credentialId = response.credentialId;
|
||||
options.transports = response.transports || [];
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object.
|
||||
* @param obj JSON object to deserialize.
|
||||
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish.
|
||||
*/
|
||||
static fromJSON(
|
||||
obj: Jsonify<WebAuthnPrfUserDecryptionOption>,
|
||||
): WebAuthnPrfUserDecryptionOption | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
@@ -104,6 +160,8 @@ export class UserDecryptionOptions {
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
|
||||
/** {@link KeyConnectorUserDecryptionOption} */
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOption;
|
||||
/** Array of {@link WebAuthnPrfUserDecryptionOption} */
|
||||
webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[];
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the UserDecryptionOptions from a response object.
|
||||
@@ -134,6 +192,18 @@ export class UserDecryptionOptions {
|
||||
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse(
|
||||
responseOptions.keyConnectorOption,
|
||||
);
|
||||
|
||||
// The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in
|
||||
// with the same PRF passkey.
|
||||
// Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array.
|
||||
if (responseOptions.webAuthnPrfOption) {
|
||||
const option = WebAuthnPrfUserDecryptionOption.fromResponse(
|
||||
responseOptions.webAuthnPrfOption,
|
||||
);
|
||||
if (option) {
|
||||
decryptionOptions.webAuthnPrfOptions = [option];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
|
||||
@@ -158,6 +228,12 @@ export class UserDecryptionOptions {
|
||||
obj?.keyConnectorOption,
|
||||
);
|
||||
|
||||
if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) {
|
||||
decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions
|
||||
.map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option))
|
||||
.filter((option) => option !== undefined);
|
||||
}
|
||||
|
||||
return decryptionOptions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
masterPasswordUnlock?: MasterPasswordUnlockResponse;
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
|
||||
/**
|
||||
* The IdTokenresponse only returns a single WebAuthn PRF option.
|
||||
* To support immediate unlock after logging in with the same PRF passkey.
|
||||
*/
|
||||
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
|
||||
|
||||
constructor(response: IUserDecryptionOptionsServerResponse) {
|
||||
|
||||
@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
|
||||
export interface IWebAuthnPrfDecryptionOptionServerResponse {
|
||||
EncryptedPrivateKey: string;
|
||||
EncryptedUserKey: string;
|
||||
CredentialId: string;
|
||||
Transports: string[];
|
||||
}
|
||||
|
||||
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
|
||||
encryptedPrivateKey: EncString;
|
||||
encryptedUserKey: EncString;
|
||||
credentialId: string;
|
||||
transports: string[];
|
||||
|
||||
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
|
||||
super(response);
|
||||
if (response.EncryptedPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
|
||||
|
||||
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
|
||||
if (encPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(encPrivateKey);
|
||||
}
|
||||
if (response.EncryptedUserKey) {
|
||||
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
|
||||
|
||||
const encUserKey = this.getResponseProperty("EncryptedUserKey");
|
||||
if (encUserKey) {
|
||||
this.encryptedUserKey = new EncString(encUserKey);
|
||||
}
|
||||
|
||||
this.credentialId = this.getResponseProperty("CredentialId");
|
||||
this.transports = this.getResponseProperty("Transports") || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ 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",
|
||||
PasskeyUnlock = "pm-2035-passkey-unlock",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
|
||||
@@ -57,6 +57,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
EventManagementForHuntress = "event-management-for-huntress",
|
||||
PhishingDetection = "phishing-detection",
|
||||
|
||||
/* Vault */
|
||||
@@ -64,8 +65,6 @@ export enum FeatureFlag {
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
|
||||
@@ -121,6 +120,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.EventManagementForHuntress]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
@@ -128,8 +128,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
@@ -152,9 +150,9 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.PasskeyUnlock]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
[FeatureFlag.PM27279_V2RegistrationTdeJit]: 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(
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response";
|
||||
|
||||
export class UserDecryptionResponse extends BaseResponse {
|
||||
masterPasswordUnlock?: MasterPasswordUnlockResponse;
|
||||
|
||||
/**
|
||||
* The sync service returns an array of WebAuthn PRF options.
|
||||
*/
|
||||
webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[];
|
||||
|
||||
constructor(response: unknown) {
|
||||
super(response);
|
||||
|
||||
@@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse {
|
||||
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
|
||||
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
|
||||
}
|
||||
|
||||
const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions");
|
||||
if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) {
|
||||
this.webAuthnPrfOptions = webAuthnPrfOptions.map(
|
||||
(option) => new WebAuthnPrfDecryptionOptionResponse(option),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
});
|
||||
|
||||
describe("getVaultTimeoutByUserId$", () => {
|
||||
beforeEach(() => {
|
||||
// Return the input value unchanged
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
|
||||
async (timeout) => timeout,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if no user id is provided", async () => {
|
||||
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
|
||||
"User id required. Cannot get vault timeout.",
|
||||
@@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
defaultVaultTimeout,
|
||||
);
|
||||
expect(result).toBe(defaultVaultTimeout);
|
||||
});
|
||||
|
||||
@@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
vaultTimeout,
|
||||
);
|
||||
expect(result).toBe(vaultTimeout);
|
||||
});
|
||||
|
||||
it("promotes timeout when unavailable on client", async () => {
|
||||
const determinedTimeout = VaultTimeoutNumberType.OnMinute;
|
||||
const promotedValue = VaultTimeoutStringType.OnRestart;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
determinedTimeout,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: custom", () => {
|
||||
@@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
policyMinutes,
|
||||
);
|
||||
expect(result).toBe(policyMinutes);
|
||||
},
|
||||
);
|
||||
@@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
vaultTimeout,
|
||||
);
|
||||
expect(result).toBe(vaultTimeout);
|
||||
},
|
||||
);
|
||||
@@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
expect(result).toBe(VaultTimeoutNumberType.Immediately);
|
||||
});
|
||||
|
||||
it("promotes policy minutes when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutStringType.Never;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(
|
||||
VAULT_TIMEOUT,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
policyMinutes,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: immediately", () => {
|
||||
@@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"when current timeout is %s, returns immediately or promoted value",
|
||||
async (currentTimeout) => {
|
||||
const expectedTimeout = VaultTimeoutNumberType.Immediately;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
|
||||
);
|
||||
@@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
expect(result).toBe(expectedTimeout);
|
||||
},
|
||||
);
|
||||
|
||||
it("promotes immediately when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutNumberType.OnMinute;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: onSystemLock", () => {
|
||||
@@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"when current timeout is %s, returns onLocked or promoted value",
|
||||
async (currentTimeout) => {
|
||||
const expectedTimeout = VaultTimeoutStringType.OnLocked;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
|
||||
);
|
||||
@@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
currentTimeout,
|
||||
);
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
|
||||
it("promotes onLocked when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutStringType.OnRestart;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: onAppRestart", () => {
|
||||
@@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
);
|
||||
expect(result).toBe(VaultTimeoutStringType.OnRestart);
|
||||
});
|
||||
|
||||
@@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
currentTimeout,
|
||||
);
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: never", () => {
|
||||
it("when current timeout is never, returns never or promoted value", async () => {
|
||||
const expectedTimeout = VaultTimeoutStringType.Never;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
it("promotes onRestart when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutStringType.Never;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "never" } }] as unknown as Policy[]),
|
||||
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
await stateProvider.setUserState(
|
||||
VAULT_TIMEOUT,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
);
|
||||
expect(result).toBe(expectedTimeout);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: never", () => {
|
||||
it.each([
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
@@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
currentTimeout,
|
||||
);
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
|
||||
it("promotes timeout when unavailable on client", async () => {
|
||||
const determinedTimeout = VaultTimeoutStringType.Never;
|
||||
const promotedValue = VaultTimeoutStringType.OnRestart;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "never" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
determinedTimeout,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
private async determineVaultTimeout(
|
||||
currentVaultTimeout: VaultTimeout | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeout | null> {
|
||||
): Promise<VaultTimeout> {
|
||||
const determinedTimeout = await this.determineVaultTimeoutInternal(
|
||||
currentVaultTimeout,
|
||||
maxSessionTimeoutPolicyData,
|
||||
);
|
||||
|
||||
// Ensures the timeout is available on this client
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout);
|
||||
}
|
||||
|
||||
private async determineVaultTimeoutInternal(
|
||||
currentVaultTimeout: VaultTimeout | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeout> {
|
||||
// if current vault timeout is null, apply the client specific default
|
||||
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
|
||||
|
||||
@@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
switch (maxSessionTimeoutPolicyData.type) {
|
||||
case "immediately":
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
return VaultTimeoutNumberType.Immediately;
|
||||
case "custom":
|
||||
case null:
|
||||
case undefined:
|
||||
@@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
|
||||
) {
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
);
|
||||
return VaultTimeoutStringType.OnLocked;
|
||||
}
|
||||
break;
|
||||
case "onAppRestart":
|
||||
@@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
break;
|
||||
case "never":
|
||||
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutStringType.Never,
|
||||
);
|
||||
}
|
||||
// Policy doesn't override user preference for "never"
|
||||
break;
|
||||
}
|
||||
return currentVaultTimeout;
|
||||
|
||||
@@ -42,6 +42,7 @@ export class Utils {
|
||||
static readonly validHosts: string[] = ["localhost"];
|
||||
static readonly originalMinimumPasswordLength = 8;
|
||||
static readonly minimumPasswordLength = 12;
|
||||
static readonly maximumPasswordLength = 128;
|
||||
static readonly DomainMatchBlacklist = new Map<string, Set<string>>([
|
||||
["google.com", new Set(["script.google.com"])],
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
LogoutReason,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
// 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
|
||||
@@ -68,7 +68,7 @@ describe("DefaultSyncService", () => {
|
||||
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
|
||||
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
|
||||
let sendApiService: MockProxy<SendApiService>;
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let avatarService: MockProxy<AvatarService>;
|
||||
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionData,
|
||||
CollectionDetailsResponse,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
@@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
UserDecryptionOptions,
|
||||
WebAuthnPrfUserDecryptionOption,
|
||||
} from "../../../../auth/src/common";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "../../../../auth/src/common/types";
|
||||
@@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
folderApiService: FolderApiServiceAbstraction,
|
||||
private organizationService: InternalOrganizationServiceAbstraction,
|
||||
sendApiService: SendApiService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private avatarService: AvatarService,
|
||||
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
@@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
);
|
||||
await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf);
|
||||
}
|
||||
|
||||
// Update WebAuthn PRF options if present
|
||||
if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) {
|
||||
try {
|
||||
// Only update if this is the active user, since setUserDecryptionOptions()
|
||||
// operates on the active user's state
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
if (activeAccount?.id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current options without blocking if they don't exist yet
|
||||
const currentUserDecryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
).catch((): UserDecryptionOptions | null => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (currentUserDecryptionOptions != null) {
|
||||
// Update the PRF options while preserving other decryption options
|
||||
const updatedOptions = Object.assign(
|
||||
new UserDecryptionOptions(),
|
||||
currentUserDecryptionOptions,
|
||||
);
|
||||
updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions
|
||||
.map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option))
|
||||
.filter((option) => option !== undefined);
|
||||
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
|
||||
activeAccount.id,
|
||||
updatedOptions,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,13 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract clear(userId?: string): Promise<void>;
|
||||
abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise<any>;
|
||||
abstract delete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<any>;
|
||||
): Promise<void>;
|
||||
abstract deleteAttachment(
|
||||
id: string,
|
||||
revisionDate: string,
|
||||
@@ -252,19 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
abstract sortCiphersByLastUsed(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract sortCiphersByLastUsedThenName(a: CipherViewLike, b: CipherViewLike): number;
|
||||
abstract getLocaleSortingFunction(): (a: CipherViewLike, b: CipherViewLike) => number;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<any>;
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
abstract softDelete(id: string | string[], userId: UserId): Promise<void>;
|
||||
abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin?: boolean,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<any>;
|
||||
): Promise<void>;
|
||||
abstract restore(
|
||||
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
|
||||
userId: UserId,
|
||||
): Promise<any>;
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<any>;
|
||||
): Promise<void>;
|
||||
abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise<void>;
|
||||
abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void>;
|
||||
abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<any>;
|
||||
abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise<void>;
|
||||
|
||||
@@ -224,6 +224,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
@@ -236,6 +239,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.organizationId = null!;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
@@ -249,6 +255,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.collectionIds = ["123"];
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherCreate")
|
||||
@@ -262,6 +271,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
@@ -328,6 +340,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.putCipher if cipher.edit is true", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.edit = true;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipher")
|
||||
@@ -341,6 +356,9 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.edit = false;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putPartialCipher")
|
||||
|
||||
@@ -1446,10 +1446,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.encryptedCiphersState(userId).update(() => ciphers);
|
||||
}
|
||||
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
return this.deleteWithServerUsingSdk(id, userId, asAdmin);
|
||||
await this.cipherSdkService.deleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (asAdmin) {
|
||||
@@ -1461,24 +1463,17 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.delete(id, userId);
|
||||
}
|
||||
|
||||
private async deleteWithServerUsingSdk(
|
||||
id: string,
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
): Promise<any> {
|
||||
await this.cipherSdkService.deleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
}
|
||||
|
||||
async deleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<any> {
|
||||
): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
return this.deleteManyWithServerUsingSdk(ids, userId, asAdmin, orgId);
|
||||
await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
@@ -1490,16 +1485,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.delete(ids, userId);
|
||||
}
|
||||
|
||||
private async deleteManyWithServerUsingSdk(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<any> {
|
||||
await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
}
|
||||
|
||||
async deleteAttachment(
|
||||
id: string,
|
||||
revisionDate: string,
|
||||
@@ -1630,7 +1615,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
};
|
||||
}
|
||||
|
||||
async softDelete(id: string | string[], userId: UserId): Promise<any> {
|
||||
async softDelete(id: string | string[], userId: UserId): Promise<void> {
|
||||
let ciphers = await firstValueFrom(this.ciphers$(userId));
|
||||
if (ciphers == null) {
|
||||
return;
|
||||
@@ -1658,10 +1643,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
return this.softDeleteWithServerUsingSdk(id, userId, asAdmin);
|
||||
await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (asAdmin) {
|
||||
@@ -1673,24 +1660,17 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.softDelete(id, userId);
|
||||
}
|
||||
|
||||
private async softDeleteWithServerUsingSdk(
|
||||
id: string,
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
): Promise<any> {
|
||||
await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
}
|
||||
|
||||
async softDeleteManyWithServer(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<any> {
|
||||
): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
return this.softDeleteManyWithServerUsingSdk(ids, userId, asAdmin, orgId);
|
||||
await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = new CipherBulkDeleteRequest(ids);
|
||||
@@ -1703,16 +1683,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.softDelete(ids, userId);
|
||||
}
|
||||
|
||||
private async softDeleteManyWithServerUsingSdk(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
orgId?: OrganizationId,
|
||||
): Promise<any> {
|
||||
await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId);
|
||||
await this.clearCache(userId);
|
||||
}
|
||||
|
||||
async restore(
|
||||
cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[],
|
||||
userId: UserId,
|
||||
@@ -1746,10 +1716,12 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<any> {
|
||||
async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
return await this.restoreWithServerUsingSdk(id, userId, asAdmin);
|
||||
await this.cipherSdkService.restoreWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
@@ -1762,15 +1734,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.restore({ id: id, revisionDate: response.revisionDate }, userId);
|
||||
}
|
||||
|
||||
private async restoreWithServerUsingSdk(
|
||||
id: string,
|
||||
userId: UserId,
|
||||
asAdmin = false,
|
||||
): Promise<any> {
|
||||
await this.cipherSdkService.restoreWithServer(id, userId, asAdmin);
|
||||
await this.clearCache(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable
|
||||
* The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore
|
||||
@@ -1778,7 +1741,9 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise<void> {
|
||||
const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$);
|
||||
if (useSdk) {
|
||||
return await this.restoreManyWithServerUsingSdk(ids, userId, orgId);
|
||||
await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId);
|
||||
await this.clearCache(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
let response;
|
||||
@@ -1798,15 +1763,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
await this.restore(restores, userId);
|
||||
}
|
||||
|
||||
private async restoreManyWithServerUsingSdk(
|
||||
ids: string[],
|
||||
userId: UserId,
|
||||
orgId?: string,
|
||||
): Promise<void> {
|
||||
await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId);
|
||||
await this.clearCache(userId);
|
||||
}
|
||||
|
||||
async getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise<UserKey | OrgKey> {
|
||||
if (cipher.organizationId == null) {
|
||||
return await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -30,21 +30,14 @@
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
|
||||
>
|
||||
@if (sideNavService.open()) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Directive, EventEmitter, Output, input, model } from "@angular/core";
|
||||
import { Directive, output, input, model } from "@angular/core";
|
||||
import { RouterLink, RouterLinkActive } from "@angular/router";
|
||||
|
||||
/**
|
||||
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`.
|
||||
* Base class for navigation components in the side navigation.
|
||||
*
|
||||
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties
|
||||
* that are passed down to `NavItemComponent`.
|
||||
*/
|
||||
@Directive()
|
||||
export abstract class NavBaseComponent {
|
||||
@@ -38,23 +41,26 @@ export abstract class NavBaseComponent {
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* @remarks
|
||||
* We can't name this "routerLink" because Angular will mount the `RouterLink` directive.
|
||||
*
|
||||
* See: {@link https://github.com/angular/angular/issues/24482}
|
||||
* @see {@link RouterLink.routerLink}
|
||||
* @see {@link https://github.com/angular/angular/issues/24482}
|
||||
*/
|
||||
readonly route = input<RouterLink["routerLink"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLink.relativeTo}
|
||||
* @see {@link RouterLink.relativeTo}
|
||||
*/
|
||||
readonly relativeTo = input<RouterLink["relativeTo"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
* @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" }
|
||||
* @see {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
*/
|
||||
readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
|
||||
paths: "subset",
|
||||
@@ -71,7 +77,5 @@ export abstract class NavBaseComponent {
|
||||
/**
|
||||
* Fires when main content is clicked
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
|
||||
readonly mainContentClicked = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@if (sideNavService.open$ | async) {
|
||||
@if (sideNavService.open()) {
|
||||
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
||||
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* A visual divider for separating navigation items in the side navigation.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-nav-divider",
|
||||
templateUrl: "./nav-divider.component.html",
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavDividerComponent {
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,8 @@
|
||||
<ng-template #button>
|
||||
<button
|
||||
type="button"
|
||||
class="tw-ms-auto"
|
||||
[ngClass]="{
|
||||
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
|
||||
}"
|
||||
class="tw-ms-auto tw-text-fg-sidenav-text"
|
||||
[class]="variantValue === 'tree' && !open() ? 'tw-transform tw-rotate-[90deg]' : ''"
|
||||
[bitIconButton]="toggleButtonIcon()"
|
||||
buttonType="nav-contrast"
|
||||
(click)="toggle($event)"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
booleanAttribute,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Optional,
|
||||
Output,
|
||||
SkipSelf,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
contentChildren,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -22,8 +19,6 @@ import { NavBaseComponent } from "./nav-base.component";
|
||||
import { NavGroupAbstraction, NavItemComponent } from "./nav-item.component";
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-nav-group",
|
||||
templateUrl: "./nav-group.component.html",
|
||||
@@ -31,20 +26,24 @@ import { SideNavService } from "./side-nav.service";
|
||||
{ provide: NavBaseComponent, useExisting: NavGroupComponent },
|
||||
{ provide: NavGroupAbstraction, useExisting: NavGroupComponent },
|
||||
],
|
||||
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
imports: [NgTemplateOutlet, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavGroupComponent extends NavBaseComponent {
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
private readonly parentNavGroup = inject(NavGroupComponent, { optional: true, skipSelf: true });
|
||||
|
||||
// Query direct children for hideIfEmpty functionality
|
||||
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false });
|
||||
|
||||
readonly sideNavOpen = toSignal(this.sideNavService.open$);
|
||||
protected readonly sideNavOpen = this.sideNavService.open;
|
||||
|
||||
readonly sideNavAndGroupOpen = computed(() => {
|
||||
return this.open() && this.sideNavOpen();
|
||||
});
|
||||
|
||||
/** When the side nav is open, the parent nav item should not show active styles when open. */
|
||||
readonly parentHideActiveStyles = computed(() => {
|
||||
protected readonly parentHideActiveStyles = computed(() => {
|
||||
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
|
||||
});
|
||||
|
||||
@@ -80,7 +79,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
/**
|
||||
* UID for `[attr.aria-controls]`
|
||||
*/
|
||||
protected contentId = Math.random().toString(36).substring(2);
|
||||
protected readonly contentId = Math.random().toString(36).substring(2);
|
||||
|
||||
/**
|
||||
* Is `true` if the expanded content is visible
|
||||
@@ -98,15 +97,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
/** Does not toggle the expanded state on click */
|
||||
readonly disableToggleOnClick = input(false, { transform: booleanAttribute });
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output()
|
||||
openChange = new EventEmitter<boolean>();
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
|
||||
) {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Set tree depth based on parent's depth
|
||||
@@ -118,9 +109,8 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
|
||||
setOpen(isOpen: boolean) {
|
||||
this.open.set(isOpen);
|
||||
this.openChange.emit(this.open());
|
||||
if (this.open()) {
|
||||
this.parentNavGroup?.setOpen(this.open());
|
||||
if (this.open() && this.parentNavGroup) {
|
||||
this.parentNavGroup.setOpen(this.open());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +120,9 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
}
|
||||
|
||||
protected handleMainContentClicked() {
|
||||
if (!this.sideNavService.open) {
|
||||
if (!this.sideNavService.open()) {
|
||||
if (!this.route()) {
|
||||
this.sideNavService.setOpen();
|
||||
this.sideNavService.open.set(true);
|
||||
}
|
||||
this.open.set(true);
|
||||
} else if (!this.disableToggleOnClick()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, importProvidersFrom } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
|
||||
|
||||
@@ -14,10 +14,9 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
|
||||
import { NavGroupComponent } from "./nav-group.component";
|
||||
import { NavigationModule } from "./navigation.module";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class DummyContentComponent {}
|
||||
|
||||
|
||||
@@ -1,53 +1,27 @@
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
@let open = sideNavService.open$ | async;
|
||||
@let open = sideNavService.open();
|
||||
@if (open || icon()) {
|
||||
<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]="fvwStyles$ | async"
|
||||
[class]="fvwStyles()"
|
||||
[class.tw-bg-bg-sidenav-active-item]="showActiveStyles"
|
||||
[class.tw-bg-bg-sidenav-background]="!showActiveStyles"
|
||||
[class.hover:tw-bg-bg-sidenav-item-hover]="!showActiveStyles"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
<ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
|
||||
[attr.aria-hidden]="open"
|
||||
[attr.aria-label]="text()"
|
||||
></i>
|
||||
}
|
||||
@if (open) {
|
||||
<span class="tw-truncate">{{ text() }}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if a value was passed to `this.route` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
@if (route()) {
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin` -->
|
||||
<!-- The following `class` field should match the button 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()"
|
||||
@@ -61,25 +35,22 @@
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.route` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
} @else {
|
||||
<!-- Class field should match anchor 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()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</button>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -88,3 +59,27 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text"
|
||||
[class]="icon()"
|
||||
[attr.aria-hidden]="open"
|
||||
[attr.aria-label]="text()"
|
||||
></i>
|
||||
}
|
||||
@if (open) {
|
||||
<span class="tw-truncate">{{ text() }}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostListener, Optional, computed, input, model } from "@angular/core";
|
||||
import { RouterLinkActive, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
import { RouterModule, RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
|
||||
@@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction {
|
||||
abstract treeDepth: ReturnType<typeof model<number>>;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-nav-item",
|
||||
templateUrl: "./nav-item.component.html",
|
||||
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
|
||||
imports: [CommonModule, IconButtonModule, RouterModule],
|
||||
imports: [NgTemplateOutlet, IconButtonModule, RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
"(focusin)": "onFocusIn($event.target)",
|
||||
"(focusout)": "onFocusOut()",
|
||||
},
|
||||
})
|
||||
export class NavItemComponent extends NavBaseComponent {
|
||||
/**
|
||||
@@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
*/
|
||||
protected readonly TREE_DEPTH_PADDING = 1.75;
|
||||
|
||||
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
|
||||
/**
|
||||
* Forces active styles to be shown, regardless of the `routerLinkActiveOptions`
|
||||
*/
|
||||
readonly forceActiveStyles = input<boolean>(false);
|
||||
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true });
|
||||
|
||||
/**
|
||||
* Is `true` if `to` matches the current route
|
||||
*/
|
||||
@@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
* adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels
|
||||
*/
|
||||
protected readonly navItemIndentationPadding = computed(() => {
|
||||
const open = this.sideNavService.open;
|
||||
const open = this.sideNavService.open();
|
||||
const depth = this.treeDepth() ?? 0;
|
||||
|
||||
if (open && this.variant() === "tree") {
|
||||
@@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
* (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some
|
||||
* styles, so the entire component can have an outline.
|
||||
*/
|
||||
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" : "",
|
||||
),
|
||||
protected readonly focusVisibleWithin = signal(false);
|
||||
protected readonly fvwStyles = computed(() =>
|
||||
this.focusVisibleWithin()
|
||||
? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus"
|
||||
: "",
|
||||
);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.focusVisibleWithin$.next(false);
|
||||
|
||||
protected onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible"));
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() private parentNavGroup: NavGroupAbstraction,
|
||||
) {
|
||||
protected onFocusOut() {
|
||||
this.focusVisibleWithin.set(false);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Set tree depth based on parent's depth
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-sticky tw-top-0 tw-z-50 tw-pb-4': sideNavService.open,
|
||||
'tw-pb-[calc(theme(spacing.8)_+_2px)]': !sideNavService.open,
|
||||
}"
|
||||
class="tw-px-2 tw-pt-2"
|
||||
[class]="
|
||||
sideNavService.open()
|
||||
? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4'
|
||||
: 'tw-pb-[calc(theme(spacing.8)_+_2px)]'
|
||||
"
|
||||
>
|
||||
<!-- 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]"
|
||||
[ngClass]="{
|
||||
'!tw-h-[55px] [&_svg]:!tw-w-[26px]': !sideNavService.open,
|
||||
}"
|
||||
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]"
|
||||
[class]="!sideNavService.open() ? '!tw-h-[55px] [&_svg]:!tw-w-[26px]' : ''"
|
||||
[attr.aria-label]="label()"
|
||||
[title]="label()"
|
||||
routerLinkActive
|
||||
ariaCurrentWhenActive="page"
|
||||
>
|
||||
<bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
|
||||
<bit-icon [icon]="sideNavService.open() ? openIcon() : closedIcon()"></bit-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core";
|
||||
import { RouterLinkActive, RouterLink } from "@angular/router";
|
||||
|
||||
import { BitwardenShield, Icon } from "@bitwarden/assets/svg";
|
||||
@@ -8,18 +7,25 @@ import { BitIconComponent } from "../icon/icon.component";
|
||||
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-nav-logo",
|
||||
templateUrl: "./nav-logo.component.html",
|
||||
imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent],
|
||||
imports: [RouterLinkActive, RouterLink, BitIconComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavLogoComponent {
|
||||
/** Icon that is displayed when the side nav is closed */
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
/**
|
||||
* Icon that is displayed when the side nav is closed
|
||||
*
|
||||
* @default BitwardenShield
|
||||
*/
|
||||
readonly closedIcon = input(BitwardenShield);
|
||||
|
||||
/** Icon that is displayed when the side nav is open */
|
||||
/**
|
||||
* Icon that is displayed when the side nav is open
|
||||
*/
|
||||
readonly openIcon = input.required<Icon>();
|
||||
|
||||
/**
|
||||
@@ -27,8 +33,8 @@ export class NavLogoComponent {
|
||||
*/
|
||||
readonly route = input.required<string | any[]>();
|
||||
|
||||
/** Passed to `attr.aria-label` and `attr.title` */
|
||||
/**
|
||||
* Passed to `attr.aria-label` and `attr.title`
|
||||
*/
|
||||
readonly label = input.required<string>();
|
||||
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,60 @@
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
isOverlay: sideNavService.isOverlay$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
|
||||
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
|
||||
[ngStyle]="
|
||||
variant() === 'secondary' && {
|
||||
'--color-text-alt2': 'var(--color-text-main)',
|
||||
'--color-background-alt3': 'var(--color-secondary-100)',
|
||||
'--color-background-alt4': 'var(--color-secondary-300)',
|
||||
'--color-hover-contrast': 'var(--color-hover-default)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@let open = sideNavService.open();
|
||||
@let isOverlay = sideNavService.isOverlay();
|
||||
|
||||
<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-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
|
||||
[style.width.rem]="open ? (sideNavService.width$ | async) : undefined"
|
||||
[style]="
|
||||
variant() === 'secondary'
|
||||
? '--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]="isOverlay"
|
||||
[attr.role]="isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!data.open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, inject, input, viewChild } from "@angular/core";
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
viewChild,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service";
|
||||
|
||||
export type SideNavVariant = "primary" | "secondary";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* Side navigation component that provides a collapsible navigation menu.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-side-nav",
|
||||
templateUrl: "side-nav.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
CdkTrapFocus,
|
||||
NavDividerComponent,
|
||||
BitIconButtonComponent,
|
||||
I18nPipe,
|
||||
DragDropModule,
|
||||
AsyncPipe,
|
||||
],
|
||||
host: {
|
||||
class: "tw-block tw-h-full",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SideNavComponent {
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
/**
|
||||
* Visual variant of the side navigation
|
||||
*
|
||||
* @default "primary"
|
||||
*/
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
|
||||
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
|
||||
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
protected handleKeyDown = (event: KeyboardEvent) => {
|
||||
protected readonly handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
this.sideNavService.setClose();
|
||||
this.sideNavService.open.set(false);
|
||||
this.toggleButton()?.nativeElement.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
map,
|
||||
startWith,
|
||||
debounceTime,
|
||||
first,
|
||||
} from "rxjs";
|
||||
import { computed, effect, inject, Injectable, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs";
|
||||
|
||||
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
|
||||
|
||||
@@ -32,16 +23,17 @@ export class SideNavService {
|
||||
|
||||
private rootFontSizePx: number;
|
||||
|
||||
private _open$ = new BehaviorSubject<boolean>(isAtOrLargerThanBreakpoint("md"));
|
||||
open$ = this._open$.asObservable();
|
||||
/**
|
||||
* Whether the side navigation is open or closed.
|
||||
*/
|
||||
readonly open = signal(isAtOrLargerThanBreakpoint("md"));
|
||||
|
||||
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
|
||||
private _userCollapsePreference$ = new BehaviorSubject<CollapsePreference>(null);
|
||||
userCollapsePreference$ = this._userCollapsePreference$.asObservable();
|
||||
readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true });
|
||||
|
||||
isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe(
|
||||
map(([open, isLargeScreen]) => open && !isLargeScreen),
|
||||
);
|
||||
readonly userCollapsePreference = signal<CollapsePreference>(null);
|
||||
|
||||
readonly isOverlay = computed(() => this.open() && !this.isLargeScreen());
|
||||
|
||||
/**
|
||||
* Local component state width
|
||||
@@ -67,16 +59,14 @@ export class SideNavService {
|
||||
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
|
||||
|
||||
// Handle open/close state
|
||||
combineLatest([this.isLargeScreen$, this.userCollapsePreference$])
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(([isLargeScreen, userCollapsePreference]) => {
|
||||
if (!isLargeScreen) {
|
||||
this.setClose();
|
||||
} else if (userCollapsePreference !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.setOpen();
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (!this.isLargeScreen()) {
|
||||
this.open.set(false);
|
||||
} else if (this.userCollapsePreference() !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.open.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the resizable width from state provider
|
||||
this.widthState$.pipe(first()).subscribe((width: number) => {
|
||||
@@ -89,31 +79,14 @@ export class SideNavService {
|
||||
});
|
||||
}
|
||||
|
||||
get open() {
|
||||
return this._open$.getValue();
|
||||
}
|
||||
|
||||
setOpen() {
|
||||
this._open$.next(true);
|
||||
}
|
||||
|
||||
setClose() {
|
||||
this._open$.next(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the open/close state of the side nav
|
||||
*/
|
||||
toggle() {
|
||||
const curr = this._open$.getValue();
|
||||
// Store user's preference based on what state they're toggling TO
|
||||
this._userCollapsePreference$.next(curr ? "closed" : "open");
|
||||
this.userCollapsePreference.set(this.open() ? "closed" : "open");
|
||||
|
||||
if (curr) {
|
||||
this.setClose();
|
||||
} else {
|
||||
this.setOpen();
|
||||
}
|
||||
this.open.set(!this.open());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
export { LockComponent } from "./lock/components/lock.component";
|
||||
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
|
||||
export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service";
|
||||
export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service";
|
||||
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
|
||||
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
|
||||
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<bit-unlock-via-prf (unlockSuccess)="onPrfUnlockSuccess($event)"></bit-unlock-via-prf>
|
||||
|
||||
<button type="button" bitButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
@@ -113,6 +115,11 @@
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<bit-unlock-via-prf
|
||||
[formButton]="true"
|
||||
(unlockSuccess)="onPrfUnlockSuccess($event)"
|
||||
></bit-unlock-via-prf>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
@@ -127,6 +134,7 @@
|
||||
[unlockOptions]="unlockOptions"
|
||||
[biometricUnlockBtnText]="biometricUnlockBtnText"
|
||||
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
|
||||
(prfUnlockSuccess)="onPrfUnlockSuccess($event)"
|
||||
(logOut)="logOut()"
|
||||
></bit-master-password-lock>
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
UnlockOptionValue,
|
||||
UnlockOptions,
|
||||
} from "../services/lock-component.service";
|
||||
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
|
||||
|
||||
import { LockComponent } from "./lock.component";
|
||||
|
||||
@@ -84,6 +85,7 @@ describe("LockComponent", () => {
|
||||
const mockLockComponentService = mock<LockComponentService>();
|
||||
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||
const mockBroadcasterService = mock<BroadcasterService>();
|
||||
const mockWebAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
|
||||
const mockEncryptedMigrator = mock<EncryptedMigrator>();
|
||||
const mockActivatedRoute = {
|
||||
snapshot: {
|
||||
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
|
||||
{ provide: LockComponentService, useValue: mockLockComponentService },
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||
{ provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
|
||||
],
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from "../services/lock-component.service";
|
||||
|
||||
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
|
||||
import { UnlockViaPrfComponent } from "./unlock-via-prf.component";
|
||||
|
||||
const BroadcasterSubscriptionId = "LockComponent";
|
||||
|
||||
@@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
UnlockViaPrfComponent,
|
||||
MasterPasswordLockComponent,
|
||||
TooltipDirective,
|
||||
],
|
||||
@@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async onPrfUnlockSuccess(userKey: UserKey): Promise<void> {
|
||||
await this.setUserKeyAndContinue(userKey);
|
||||
}
|
||||
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
}
|
||||
|
||||
private validatePin(): boolean {
|
||||
if (this.formGroup?.invalid) {
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<bit-unlock-via-prf
|
||||
[formButton]="true"
|
||||
(unlockSuccess)="onPrfUnlockSuccess($event)"
|
||||
></bit-unlock-via-prf>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
@@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
|
||||
import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service";
|
||||
|
||||
import { MasterPasswordLockComponent } from "./master-password-lock.component";
|
||||
|
||||
@@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => {
|
||||
const logService = mock<LogService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const messageListener = mock<MessageListener>();
|
||||
const webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
|
||||
const dialogService = mock<DialogService>();
|
||||
|
||||
const mockMasterPassword = "testExample";
|
||||
const activeAccount: Account = {
|
||||
@@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
},
|
||||
prf: { enabled: false },
|
||||
};
|
||||
|
||||
accountService.activeAccount$ = of(account);
|
||||
@@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => {
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||
{ provide: MessageListener, useValue: messageListener },
|
||||
{ provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
UnlockOptions,
|
||||
UnlockOptionValue,
|
||||
} from "../../services/lock-component.service";
|
||||
import { UnlockViaPrfComponent } from "../unlock-via-prf.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
UnlockViaPrfComponent,
|
||||
],
|
||||
})
|
||||
export class MasterPasswordLockComponent implements OnInit, OnDestroy {
|
||||
@@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
|
||||
prfUnlockSuccess = output<UserKey>();
|
||||
logOut = output<void>();
|
||||
|
||||
protected showPassword = false;
|
||||
@@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPrfUnlockSuccess(userKey: UserKey): void {
|
||||
this.prfUnlockSuccess.emit(userKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, input, output } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-unlock-via-prf",
|
||||
standalone: true,
|
||||
imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule],
|
||||
template: `
|
||||
@if (isAvailable) {
|
||||
@if (formButton()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="unlockViaPrf()"
|
||||
[disabled]="unlocking"
|
||||
[loading]="unlocking"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "unlockWithPasskey" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (!formButton()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="unlockViaPrf()"
|
||||
[disabled]="unlocking"
|
||||
[loading]="unlocking"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "unlockWithPasskey" | i18n }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UnlockViaPrfComponent implements OnInit {
|
||||
readonly formButton = input<boolean>(false);
|
||||
readonly unlockSuccess = output<UserKey>();
|
||||
|
||||
unlocking = false;
|
||||
isAvailable = false;
|
||||
private userId: UserId | null = null;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private webAuthnPrfUnlockService: WebAuthnPrfUnlockService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (activeAccount?.id) {
|
||||
this.userId = activeAccount.id;
|
||||
this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId);
|
||||
}
|
||||
}
|
||||
|
||||
async unlockViaPrf(): Promise<void> {
|
||||
if (!this.userId || !this.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unlocking = true;
|
||||
|
||||
try {
|
||||
const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId);
|
||||
this.unlockSuccess.emit(userKey);
|
||||
} catch (error) {
|
||||
this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error);
|
||||
|
||||
let errorMessage = this.i18nService.t("unexpectedError");
|
||||
|
||||
// Handle specific PRF error cases
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("No PRF credentials")) {
|
||||
errorMessage = this.i18nService.t("noPrfCredentialsAvailable");
|
||||
} else if (error.message.includes("canceled")) {
|
||||
// User canceled the operation, don't show error
|
||||
this.unlocking = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "error" },
|
||||
content: errorMessage,
|
||||
acceptButtonText: { key: "ok" },
|
||||
type: "danger",
|
||||
});
|
||||
} finally {
|
||||
this.unlocking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
WebAuthnPrfUserDecryptionOption,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service";
|
||||
|
||||
export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService {
|
||||
private navigatorCredentials: CredentialsContainer;
|
||||
|
||||
constructor(
|
||||
private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
private keyService: KeyService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private encryptService: EncryptService,
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private window: Window,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.navigatorCredentials = this.window.navigator.credentials;
|
||||
}
|
||||
|
||||
async isPrfUnlockAvailable(userId: UserId): Promise<boolean> {
|
||||
try {
|
||||
// Check if feature flag is enabled
|
||||
const passkeyUnlockEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PasskeyUnlock,
|
||||
);
|
||||
if (!passkeyUnlockEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if browser supports WebAuthn
|
||||
if (!this.navigatorCredentials || !this.navigatorCredentials.get) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're in the browser extension, check if we're in a Chromium browser
|
||||
if (
|
||||
this.platformUtilsService.getClientType() === ClientType.Browser &&
|
||||
!this.platformUtilsService.isChromium()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has any WebAuthn PRF credentials registered
|
||||
const credentials = await this.getPrfUnlockCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logService.error("Error checking PRF unlock availability:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async getPrfUnlockCredentials(
|
||||
userId: UserId,
|
||||
): Promise<{ credentialId: string; transports: string[] }[]> {
|
||||
try {
|
||||
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
|
||||
if (!userDecryptionOptions?.webAuthnPrfOptions) {
|
||||
return [];
|
||||
}
|
||||
return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({
|
||||
credentialId: option.credentialId,
|
||||
transports: option.transports,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting PRF unlock credentials:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the vault using WebAuthn PRF.
|
||||
*
|
||||
* @param userId The user ID to unlock vault for
|
||||
* @returns Promise<UserKey> the decrypted user key
|
||||
* @throws Error if unlock fails for any reason
|
||||
*/
|
||||
async unlockVaultWithPrf(userId: UserId): Promise<UserKey> {
|
||||
// Get offline PRF credentials from user decryption options
|
||||
const credentials = await this.getPrfUnlockCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error("No PRF credentials available for unlock");
|
||||
}
|
||||
|
||||
const response = await this.performWebAuthnGetWithPrf(credentials, userId);
|
||||
const prfKey = await this.createPrfKeyFromResponse(response);
|
||||
const prfOption = await this.getPrfOptionForCredential(response.id, userId);
|
||||
|
||||
// PRF unlock follows the same key derivation process as PRF login:
|
||||
// PRF key → decrypt private key → use private key to decrypt user key
|
||||
|
||||
// Step 1: Decrypt PRF encrypted private key using the PRF key
|
||||
const privateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||
new EncString(prfOption.encryptedPrivateKey),
|
||||
prfKey,
|
||||
);
|
||||
|
||||
// Step 2: Use private key to decrypt user key
|
||||
const userKey = await this.encryptService.decapsulateKeyUnsigned(
|
||||
new EncString(prfOption.encryptedUserKey),
|
||||
privateKey,
|
||||
);
|
||||
|
||||
if (!userKey) {
|
||||
throw new Error("Failed to decrypt user key from private key");
|
||||
}
|
||||
|
||||
return userKey as UserKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs WebAuthn get operation with PRF extension.
|
||||
*
|
||||
* @param credentials Available PRF credentials for the user
|
||||
* @returns PublicKeyCredential response from the authenticator
|
||||
* @throws Error if WebAuthn operation fails or returns invalid response
|
||||
*/
|
||||
private async performWebAuthnGetWithPrf(
|
||||
credentials: { credentialId: string; transports: string[] }[],
|
||||
userId: UserId,
|
||||
): Promise<PublicKeyCredential> {
|
||||
const rpId = await this.getRpIdForUser(userId);
|
||||
const prfSalt = await this.getUnlockWithPrfSalt();
|
||||
|
||||
const options: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(32),
|
||||
allowCredentials: credentials.map(({ credentialId, transports }) => {
|
||||
// The credential ID is already base64url encoded from login storage
|
||||
// We need to decode it to ArrayBuffer for WebAuthn
|
||||
const decodedId = Fido2Utils.stringToBuffer(credentialId);
|
||||
return {
|
||||
type: "public-key",
|
||||
id: decodedId,
|
||||
transports: (transports || []) as AuthenticatorTransport[],
|
||||
};
|
||||
}),
|
||||
rpId,
|
||||
userVerification: "preferred", // Allow platform authenticators to work properly
|
||||
extensions: {
|
||||
prf: { eval: { first: prfSalt } },
|
||||
} as any,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.navigatorCredentials.get(options);
|
||||
|
||||
if (!response) {
|
||||
throw new Error("WebAuthn get() returned null/undefined");
|
||||
}
|
||||
|
||||
if (!(response instanceof PublicKeyCredential)) {
|
||||
throw new Error("Failed to get PRF credential for unlock");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts PRF result from WebAuthn response and creates a PrfKey.
|
||||
*
|
||||
* @param response PublicKeyCredential response from authenticator
|
||||
* @returns PrfKey derived from the PRF extension output
|
||||
* @throws Error if no PRF result is present in the response
|
||||
*/
|
||||
private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise<PrfKey> {
|
||||
// Extract PRF result
|
||||
// TODO: Remove `any` when typescript typings add support for PRF
|
||||
const extensionResults = response.getClientExtensionResults() as any;
|
||||
const prfResult = extensionResults.prf?.results?.first;
|
||||
if (!prfResult) {
|
||||
throw new Error("No PRF result received from authenticator");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult);
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to create unlock key from PRF:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WebAuthn PRF option that matches the credential used in the response.
|
||||
*
|
||||
* @param credentialId Credential ID to match
|
||||
* @param userId User ID to get decryption options for
|
||||
* @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys
|
||||
* @throws Error if no PRF options exist or no matching option is found
|
||||
*/
|
||||
private async getPrfOptionForCredential(
|
||||
credentialId: string,
|
||||
userId: UserId,
|
||||
): Promise<WebAuthnPrfUserDecryptionOption> {
|
||||
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
|
||||
|
||||
if (
|
||||
!userDecryptionOptions?.webAuthnPrfOptions ||
|
||||
userDecryptionOptions.webAuthnPrfOptions.length === 0
|
||||
) {
|
||||
throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock");
|
||||
}
|
||||
|
||||
const prfOption = userDecryptionOptions.webAuthnPrfOptions.find(
|
||||
(option) => option.credentialId === credentialId,
|
||||
);
|
||||
|
||||
if (!prfOption) {
|
||||
throw new Error("No matching WebAuthn PRF option found for this credential");
|
||||
}
|
||||
|
||||
return prfOption;
|
||||
}
|
||||
|
||||
private async getUnlockWithPrfSalt(): Promise<ArrayBuffer> {
|
||||
try {
|
||||
// Use the same salt as login to ensure PRF keys match
|
||||
return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt();
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting unlock PRF salt:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get user decryption options for a user
|
||||
*/
|
||||
private async getUserDecryptionOptions(userId: UserId): Promise<UserDecryptionOptions | null> {
|
||||
try {
|
||||
return (await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
)) as UserDecryptionOptions;
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting user decryption options:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get the appropriate rpId for WebAuthn PRF operations
|
||||
* Returns the hostname from the user's environment configuration
|
||||
*/
|
||||
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
|
||||
try {
|
||||
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
|
||||
const hostname = environment.getHostname();
|
||||
|
||||
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
|
||||
if (!hostname) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extract hostname using URL parsing to handle IPv6 and ports correctly
|
||||
// This removes ports etc.
|
||||
const url = new URL(`https://${hostname}`);
|
||||
const rpId = url.hostname;
|
||||
|
||||
return rpId;
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting rpId", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({
|
||||
MasterPassword: "masterPassword",
|
||||
Pin: "pin",
|
||||
Biometrics: "biometrics",
|
||||
Prf: "prf",
|
||||
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
|
||||
|
||||
export type UnlockOptions = {
|
||||
@@ -23,6 +24,9 @@ export type UnlockOptions = {
|
||||
enabled: boolean;
|
||||
biometricsStatus: BiometricsStatus;
|
||||
};
|
||||
prf: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* Service for unlocking vault using WebAuthn PRF.
|
||||
* Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs.
|
||||
*/
|
||||
export abstract class WebAuthnPrfUnlockService {
|
||||
/**
|
||||
* Check if PRF unlock is available for the current user
|
||||
* @param userId The user ID to check PRF unlock availability for
|
||||
* @returns Promise<boolean> true if PRF unlock is available
|
||||
*/
|
||||
abstract isPrfUnlockAvailable(userId: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Attempt to unlock the vault using WebAuthn PRF
|
||||
* @param userId The user ID to unlock vault for
|
||||
* @returns Promise<UserKey> the decrypted user key
|
||||
* @throws Error if no PRF credentials are available
|
||||
* @throws Error if the authenticator returns no PRF result
|
||||
* @throws Error if the user cancels the WebAuthn operation
|
||||
* @throws Error if decryption of the user key fails
|
||||
* @throws Error if no matching PRF option is found for the credential
|
||||
*/
|
||||
abstract unlockVaultWithPrf(userId: UserId): Promise<UserKey>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
|
||||
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -259,7 +260,18 @@ describe("keyService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the Auto key if vault timeout is set to anything other than null", async () => {
|
||||
it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => {
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
|
||||
platformUtilService.getClientType.mockReturnValue(ClientType.Cli);
|
||||
|
||||
await keyService.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, {
|
||||
userId: mockUserId,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the Auto key if vault timeout is set to 10 minutes", async () => {
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
|
||||
|
||||
await keyService.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
|
||||
import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key";
|
||||
import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
|
||||
@@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) {
|
||||
let shouldStoreKey = false;
|
||||
switch (keySuffix) {
|
||||
case KeySuffixOptions.Auto: {
|
||||
// Cli has fixed Never vault timeout, and it should not be affected by a policy.
|
||||
if (this.platformUtilService.getClientType() == ClientType.Cli) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between
|
||||
// the VaultTimeoutSettingsSvc and this service.
|
||||
// This should be fixed as part of the PM-7082 - Auto Key Service work.
|
||||
@@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
.pipe(filter((timeout) => timeout != null)),
|
||||
);
|
||||
|
||||
shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never;
|
||||
break;
|
||||
this.logService.debug(
|
||||
`[KeyService] Should store auto key for vault timeout ${vaultTimeout}`,
|
||||
);
|
||||
|
||||
return vaultTimeout == VaultTimeoutStringType.Never;
|
||||
}
|
||||
}
|
||||
return shouldStoreKey;
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async getKeyFromStorage(
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -42,11 +41,9 @@ describe("CipherViewComponent", () => {
|
||||
let mockLogService: LogService;
|
||||
let mockCipherRiskService: CipherRiskService;
|
||||
let mockBillingAccountProfileStateService: BillingAccountProfileStateService;
|
||||
let mockConfigService: ConfigService;
|
||||
|
||||
// Mock data
|
||||
let mockCipherView: CipherView;
|
||||
let featureFlagEnabled$: BehaviorSubject<boolean>;
|
||||
let hasPremiumFromAnySource$: BehaviorSubject<boolean>;
|
||||
let activeAccount$: BehaviorSubject<Account>;
|
||||
|
||||
@@ -57,7 +54,6 @@ describe("CipherViewComponent", () => {
|
||||
email: "test@example.com",
|
||||
} as Account);
|
||||
|
||||
featureFlagEnabled$ = new BehaviorSubject(false);
|
||||
hasPremiumFromAnySource$ = new BehaviorSubject(true);
|
||||
|
||||
// Create service mocks
|
||||
@@ -83,9 +79,6 @@ describe("CipherViewComponent", () => {
|
||||
.fn()
|
||||
.mockReturnValue(hasPremiumFromAnySource$);
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$);
|
||||
|
||||
// Setup mock cipher view
|
||||
mockCipherView = new CipherView();
|
||||
mockCipherView.id = "cipher-id";
|
||||
@@ -110,7 +103,6 @@ describe("CipherViewComponent", () => {
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
@@ -145,7 +137,6 @@ describe("CipherViewComponent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset observables to default values for this test suite
|
||||
featureFlagEnabled$.next(true);
|
||||
hasPremiumFromAnySource$.next(true);
|
||||
|
||||
// Setup default mock for computeCipherRiskForUser (individual tests can override)
|
||||
@@ -162,18 +153,6 @@ describe("CipherViewComponent", () => {
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("returns false when feature flag is disabled", fakeAsync(() => {
|
||||
featureFlagEnabled$.next(false);
|
||||
|
||||
const cipher = createLoginCipherView();
|
||||
fixture.componentRef.setInput("cipher", cipher);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
|
||||
expect(component.passwordIsAtRisk()).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false when cipher has no login password", fakeAsync(() => {
|
||||
const cipher = createLoginCipherView();
|
||||
cipher.login = {} as any; // No password
|
||||
|
||||
@@ -13,8 +13,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getByIds } from "@bitwarden/common/platform/misc";
|
||||
@@ -113,7 +111,6 @@ export class CipherViewComponent {
|
||||
private logService: LogService,
|
||||
private cipherRiskService: CipherRiskService,
|
||||
private billingAccountService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
readonly resolvedCollections = toSignal<CollectionView[] | undefined>(
|
||||
@@ -248,19 +245,9 @@ export class CipherViewComponent {
|
||||
* The password is only evaluated when the user is premium and has edit access to the cipher.
|
||||
*/
|
||||
readonly passwordIsAtRisk = toSignal(
|
||||
combineLatest([
|
||||
this.activeUserId$,
|
||||
this.cipher$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium),
|
||||
]).pipe(
|
||||
switchMap(([userId, cipher, featureEnabled]) => {
|
||||
if (
|
||||
!featureEnabled ||
|
||||
!cipher.hasLoginPassword ||
|
||||
!cipher.edit ||
|
||||
cipher.organizationId ||
|
||||
cipher.isDeleted
|
||||
) {
|
||||
combineLatest([this.activeUserId$, this.cipher$]).pipe(
|
||||
switchMap(([userId, cipher]) => {
|
||||
if (!cipher.hasLoginPassword || !cipher.edit || cipher.organizationId || cipher.isDeleted) {
|
||||
return of(false);
|
||||
}
|
||||
return this.switchPremium$(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
@@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
|
||||
import { VaultCarouselComponent } from "./carousel.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-test-carousel-slide",
|
||||
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<vault-carousel label="Storybook Demo">
|
||||
<vault-carousel-slide label="First Slide">
|
||||
@@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => {
|
||||
const backButton = fixture.debugElement.queryAll(By.css("button"))[0];
|
||||
|
||||
middleSlideButton.nativeElement.click();
|
||||
await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update.
|
||||
|
||||
fixture.detectChanges();
|
||||
jest.spyOn(component.slideChange, "emit");
|
||||
|
||||
backButton.nativeElement.click();
|
||||
|
||||
@@ -22,7 +22,6 @@ import { take } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule, IconButtonModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component";
|
||||
import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component";
|
||||
@@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com
|
||||
ButtonModule,
|
||||
VaultCarouselContentComponent,
|
||||
VaultCarouselButtonComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
})
|
||||
export class VaultCarouselComponent implements AfterViewInit {
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[label]="'downloadAttachmentName' | i18n: attachment().fileName"
|
||||
[label]="'downloadAttachmentLabel' | i18n"
|
||||
></button>
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => {
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName");
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel");
|
||||
});
|
||||
|
||||
describe("download attachment", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -43,6 +44,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
let mockEventCollectionService: MockProxy<EventCollectionService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let mockSyncService: MockProxy<SyncService>;
|
||||
|
||||
const userId = "user-id" as UserId;
|
||||
const organizationId = "org-id" as OrganizationId;
|
||||
@@ -79,6 +81,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockEventCollectionService = mock<EventCollectionService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockOrganizationUserApiService = mock<OrganizationUserApiService>();
|
||||
mockSyncService = mock<SyncService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
transferInProgressValues = [];
|
||||
@@ -95,6 +98,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockEventCollectionService,
|
||||
mockConfigService,
|
||||
mockOrganizationUserApiService,
|
||||
mockSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -557,6 +561,8 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
mockSyncService.fullSync.mockResolvedValue(true);
|
||||
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
it("does nothing when feature flag is disabled", async () => {
|
||||
@@ -635,11 +641,11 @@ 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(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "leftOrganization",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
@@ -54,6 +55,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
private eventCollectionService: EventCollectionService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private syncService: SyncService,
|
||||
) {}
|
||||
|
||||
private _transferInProgressSubject = new BehaviorSubject(false);
|
||||
@@ -164,7 +166,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
|
||||
if (!userAcceptedTransfer) {
|
||||
await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("leftOrganization"),
|
||||
@@ -176,6 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
undefined,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
);
|
||||
// Sync to reflect organization removal
|
||||
await this.syncService.fullSync(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user