1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 09:03:28 +00:00

Merge branch 'main' into km/pm-27331

This commit is contained in:
Thomas Avery
2026-02-11 11:37:46 -06:00
committed by GitHub
158 changed files with 3291 additions and 1005 deletions

View File

@@ -15,6 +15,7 @@ export enum FeatureFlag {
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
DefaultUserCollectionRestore = "pm-30883-my-items-restored-users",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
BulkReinviteUI = "pm-28416-bulk-reinvite-ux-improvements",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -75,6 +76,7 @@ export enum FeatureFlag {
/* Platform */
ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework",
WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
@@ -109,6 +111,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.DefaultUserCollectionRestore]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
[FeatureFlag.BulkReinviteUI]: FALSE,
/* Autofill */
[FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE,
@@ -169,6 +172,7 @@ export const DefaultFeatureFlagValue = {
/* Platform */
[FeatureFlag.ContentScriptIpcChannelFramework]: FALSE,
[FeatureFlag.WebAuthnRelatedOrigins]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,

View File

@@ -2,101 +2,377 @@ import { isValidRpId } from "./domain-utils";
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm.
describe("validateRpId", () => {
it("should not be valid when rpId is null", () => {
const origin = "example.com";
let mockFetch: jest.Mock;
let webAuthnRelatedOriginsFeatureFlag = false;
expect(isValidRpId(null, origin)).toBe(false);
beforeEach(() => {
mockFetch = jest.fn();
// Default: ROR requests fail (no .well-known/webauthn endpoint)
mockFetch.mockRejectedValue(new Error("Network error"));
});
it("should not be valid when origin is null", () => {
const rpId = "example.com";
describe("classic domain validation", () => {
it("should not be valid when rpId is null", async () => {
const origin = "example.com";
expect(isValidRpId(rpId, null)).toBe(false);
expect(await isValidRpId(null, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false);
});
it("should not be valid when origin is null", async () => {
const rpId = "example.com";
expect(await isValidRpId(rpId, null, webAuthnRelatedOriginsFeatureFlag)).toBe(false);
});
it("should not be valid when rpId is more specific than origin", async () => {
const rpId = "sub.login.bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should not be valid when effective domains of rpId and origin do not match", async () => {
const rpId = "passwordless.dev";
const origin = "https://login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", async () => {
const rpId = "login.passwordless.dev";
const origin = "https://login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should not be valid when rpId and origin are both different TLD", async () => {
const rpId = "bitwarden";
const origin = "localhost";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
// Only allow localhost for rpId, need to properly investigate the implications of
// adding support for ip-addresses and other TLDs
it("should not be valid when rpId and origin are both the same TLD", async () => {
const rpId = "bitwarden";
const origin = "bitwarden";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should not be valid when rpId and origin are ip-addresses", async () => {
const rpId = "127.0.0.1";
const origin = "127.0.0.1";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should be valid when domains of rpId and origin are localhost", async () => {
const rpId = "localhost";
const origin = "https://localhost:8080";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should be valid when domains of rpId and origin are the same", async () => {
const rpId = "bitwarden.com";
const origin = "https://bitwarden.com";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should be valid when origin is a subdomain of rpId", async () => {
const rpId = "bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should be valid when domains of rpId and origin are the same and they are both subdomains", async () => {
const rpId = "login.bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should be valid when origin is a subdomain of rpId and they are both subdomains", async () => {
const rpId = "login.bitwarden.com";
const origin = "https://sub.login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should not be valid for a partial match of a subdomain", async () => {
const rpId = "accounts.example.com";
const origin = "https://evilaccounts.example.com";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag)).toBe(false);
});
});
it("should not be valid when rpId is more specific than origin", () => {
const rpId = "sub.login.bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
describe("Related Origin Requests (ROR)", () => {
// Helper to create a mock fetch response
function mockRorResponse(origins: string[], status = 200, contentType = "application/json") {
mockFetch.mockResolvedValue({
ok: status >= 200 && status < 300,
status,
headers: new Headers({ "content-type": contentType }),
json: async () => ({ origins }),
});
}
expect(isValidRpId(rpId, origin)).toBe(false);
});
it("should not proceed with ROR check when valid when feature flag disabled", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
it("should not be valid when effective domains of rpId and origin do not match", () => {
const rpId = "passwordless.dev";
const origin = "https://login.bitwarden.com:1337";
mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]);
expect(isValidRpId(rpId, origin)).toBe(false);
});
expect(await isValidRpId(rpId, origin, false, mockFetch)).toBe(false);
expect(mockFetch).not.toHaveBeenCalledWith(
`https://${rpId}/.well-known/webauthn`,
expect.objectContaining({
credentials: "omit",
referrerPolicy: "no-referrer",
}),
);
});
it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => {
const rpId = "login.passwordless.dev";
const origin = "https://login.bitwarden.com:1337";
webAuthnRelatedOriginsFeatureFlag = true;
expect(isValidRpId(rpId, origin)).toBe(false);
});
it("should be valid when origin is listed in .well-known/webauthn", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
it("should not be valid when rpId and origin are both different TLD", () => {
const rpId = "bitwarden";
const origin = "https://localhost";
mockRorResponse([origin, "https://www.facebook.com", "https://www.instagram.com"]);
expect(isValidRpId(rpId, origin)).toBe(false);
});
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
expect(mockFetch).toHaveBeenCalledWith(
`https://${rpId}/.well-known/webauthn`,
expect.objectContaining({
credentials: "omit",
referrerPolicy: "no-referrer",
}),
);
});
// Only allow localhost for rpId, need to properly investigate the implications of
// adding support for ip-addresses and other TLDs
it("should not be valid when rpId and origin are both the same TLD", () => {
const rpId = "bitwarden";
const origin = "https://bitwarden";
it("should not be valid when origin is not listed in .well-known/webauthn", async () => {
const rpId = "accounts.meta.com";
const origin = "https://evil.com";
expect(isValidRpId(rpId, origin)).toBe(false);
});
mockRorResponse(["https://www.facebook.com", "https://www.instagram.com"]);
it("should not be valid when rpId and origin are ip-addresses", () => {
const rpId = "127.0.0.1";
const origin = "https://127.0.0.1";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
expect(isValidRpId(rpId, origin)).toBe(false);
});
it("should not be valid when .well-known/webauthn returns non-200 status", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
it("should be valid when domains of rpId and origin are localhost", () => {
const rpId = "localhost";
const origin = "https://localhost:8080";
mockFetch.mockResolvedValue({
ok: false,
status: 404,
headers: new Headers({ "content-type": "application/json" }),
});
expect(isValidRpId(rpId, origin)).toBe(true);
});
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should be valid when domains of rpId and origin are the same", () => {
const rpId = "bitwarden.com";
const origin = "https://bitwarden.com";
it("should not be valid when .well-known/webauthn returns non-JSON content-type", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
expect(isValidRpId(rpId, origin)).toBe(true);
});
mockRorResponse([origin], 200, "text/html");
it("should be valid when origin is a subdomain of rpId", () => {
const rpId = "bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
expect(isValidRpId(rpId, origin)).toBe(true);
});
it("should not be valid when .well-known/webauthn response has no origins array", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => {
const rpId = "login.bitwarden.com";
const origin = "https://login.bitwarden.com:1337";
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ notOrigins: "invalid" }),
});
expect(isValidRpId(rpId, origin)).toBe(true);
});
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => {
const rpId = "login.bitwarden.com";
const origin = "https://sub.login.bitwarden.com:1337";
it("should not be valid when .well-known/webauthn response has empty origins array", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
expect(isValidRpId(rpId, origin)).toBe(true);
});
mockRorResponse([]);
it("should not be valid for a partial match of a subdomain", () => {
const rpId = "accounts.example.com";
const origin = "https://evilaccounts.example.com";
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
expect(isValidRpId(rpId, origin)).toBe(false);
it("should not be valid when .well-known/webauthn response has non-string origins", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ origins: [123, { url: origin }] }),
});
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should not be valid when fetch throws an error", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
mockFetch.mockRejectedValue(new Error("Network error"));
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should not be valid when fetch times out", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
mockFetch.mockRejectedValue(new DOMException("The operation was aborted.", "AbortError"));
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should skip classic validation and use ROR when domains do not match", async () => {
// This is the Facebook/Meta use case
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
mockRorResponse([origin]);
// Classic validation would fail (different domains), but ROR should succeed
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should not call ROR endpoint when classic validation succeeds", async () => {
const rpId = "bitwarden.com";
const origin = "https://bitwarden.com";
// Classic validation succeeds, so ROR should not be called
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
expect(mockFetch).not.toHaveBeenCalled();
});
it("should require exact origin match (including port)", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com:8443";
// Only the non-port version is listed
mockRorResponse(["https://accountscenter.facebook.com"]);
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should handle invalid URLs in origins array gracefully", async () => {
const rpId = "accounts.meta.com";
const origin = "https://accountscenter.facebook.com";
mockFetch.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({
origins: ["not-a-valid-url", "://also-invalid", origin],
}),
});
// Should still find the valid origin despite invalid entries
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
it("should enforce max labels limit", async () => {
const rpId = "example.com";
const origin = "https://site6.com";
// Create origins from 6 different eTLD+1 labels
// Only the first 5 should be processed
mockRorResponse([
"https://site1.com",
"https://site2.com",
"https://site3.com",
"https://site4.com",
"https://site5.com",
"https://site6.com", // This is the 6th label, should be skipped
]);
// The origin is in the list but should be skipped due to max labels limit
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
false,
);
});
it("should allow multiple origins from the same eTLD+1", async () => {
const rpId = "example.com";
const origin = "https://sub2.facebook.com";
// All these are from facebook.com (same eTLD+1), so they count as 1 label
mockRorResponse([
"https://www.facebook.com",
"https://sub1.facebook.com",
"https://sub2.facebook.com",
"https://sub3.facebook.com",
]);
expect(await isValidRpId(rpId, origin, webAuthnRelatedOriginsFeatureFlag, mockFetch)).toBe(
true,
);
});
});
});

View File

@@ -1,22 +1,39 @@
import { parse } from "tldts";
/**
* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications.
* Maximum number of unique eTLD+1 labels to process when checking Related Origin Requests.
* This limit prevents malicious servers from causing excessive processing.
* Per WebAuthn spec recommendation.
*/
const ROR_MAX_LABELS = 5;
/**
* Timeout in milliseconds for fetching the .well-known/webauthn endpoint.
*/
const ROR_FETCH_TIMEOUT_MS = 5000;
/**
* Validates whether a Relying Party ID (rpId) is valid for a given origin according to classic
* WebAuthn specifications (before Related Origin Requests extension).
*
* The validation enforces the following rules:
* - The origin must use the HTTPS scheme
* This implements the core WebAuthn RP ID validation logic:
* - The origin must use the HTTPS scheme (except localhost)
* - Both rpId and origin must be valid domain names (not IP addresses)
* - Both must have the same registrable domain (e.g., example.com)
* - Both must have the same registrable domain (eTLD+1)
* - The origin must either exactly match the rpId or be a subdomain of it
* - Single-label domains are rejected unless they are 'localhost'
* - Localhost is always valid when both rpId and origin are localhost
*
* This is used internally as the first validation step before falling back to
* Related Origin Requests (ROR) validation.
*
* @see https://www.w3.org/TR/webauthn-2/#rp-id
*
* @param rpId - The Relying Party identifier to validate
* @param origin - The origin URL to validate against (must start with https://)
* @returns `true` if the rpId is valid for the given origin, `false` otherwise
*
*/
export function isValidRpId(rpId: string, origin: string) {
function isValidRpIdInternal(rpId: string, origin: string) {
if (!rpId || !origin) {
return false;
}
@@ -73,6 +90,148 @@ export function isValidRpId(rpId: string, origin: string) {
if (parsedOrigin.hostname != null && parsedOrigin.hostname.endsWith("." + rpId)) {
return true;
}
return false;
}
/**
* Checks if the origin is allowed to use the given rpId via Related Origin Requests (ROR).
* This implements the WebAuthn Related Origin Requests spec which allows an RP to
* authorize origins from different domains to use its rpId.
*
* @see https://w3c.github.io/webauthn/#sctn-related-origins
*
* @param rpId - The relying party ID being requested
* @param origin - The origin making the WebAuthn request
* @param fetchFn - Optional fetch function for testing, defaults to global fetch
* @returns Promise that resolves to true if the origin is allowed via ROR, false otherwise
*/
async function isAllowedByRor(
rpId: string,
origin: string,
fetchFn?: typeof fetch,
): Promise<boolean> {
try {
const fetchImpl = fetchFn ?? globalThis.fetch;
// Create abort signal with timeout - use AbortSignal.timeout if available, otherwise use AbortController
let signal: AbortSignal;
if (typeof AbortSignal.timeout === "function") {
signal = AbortSignal.timeout(ROR_FETCH_TIMEOUT_MS);
} else {
const controller = new AbortController();
setTimeout(() => controller.abort(), ROR_FETCH_TIMEOUT_MS);
signal = controller.signal;
}
const response = await fetchImpl(`https://${rpId}/.well-known/webauthn`, {
credentials: "omit",
referrerPolicy: "no-referrer",
signal,
});
if (!response.ok) {
return false;
}
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
return false;
}
const data = (await response.json()) as { origins?: unknown };
if (
!data ||
!Array.isArray(data.origins) ||
!data.origins.every((o) => typeof o === "string") ||
data.origins.length === 0
) {
return false;
}
// Track unique labels (eTLD+1) to enforce the max labels limit
const labelsSeen = new Set<string>();
for (const allowedOrigin of data.origins as string[]) {
try {
const url = new URL(allowedOrigin);
const hostname = url.hostname;
if (!hostname) {
continue;
}
const parsed = parse(hostname, { allowPrivateDomains: true });
if (!parsed.domain || !parsed.publicSuffix) {
continue;
}
// Extract the label (the part before the public suffix)
const label = parsed.domain.slice(0, parsed.domain.length - parsed.publicSuffix.length - 1);
if (!label) {
continue;
}
// Skip if we've already seen max labels and this is a new one
if (labelsSeen.size >= ROR_MAX_LABELS && !labelsSeen.has(label)) {
continue;
}
// Check for exact origin match
if (origin === allowedOrigin) {
return true;
}
// Track the label if we haven't hit the limit
if (labelsSeen.size < ROR_MAX_LABELS) {
labelsSeen.add(label);
}
} catch {
// Invalid URL, skip this entry
continue;
}
}
return false;
} catch {
// Network error, timeout, or other failure - fail closed
return false;
}
}
/* Validates whether a Relying Party ID (rpId) is valid for a given origin according to WebAuthn specifications.
* If that fails, checks if the origin is authorized via Related Origin Requests (ROR).
*
* The validation enforces the following rules:
* - The origin must use the HTTPS scheme
* - Both rpId and origin must be valid domain names (not IP addresses)
* - Both must have the same registrable domain (e.g., example.com)
* - The origin must either exactly match the rpId or be a subdomain of it
* - Single-label domains are rejected unless they are 'localhost'
* - Localhost is always valid when both rpId and origin are localhost
*
* @param rpId - The Relying Party identifier to validate
* @param origin - The origin URL to validate against (must start with https://)
* @param fetchFn - Optional fetch function for testing, defaults to global fetch
* @returns `true` if the rpId is valid for the given origin, `false` otherwise
*
*/
export async function isValidRpId(
rpId: string,
origin: string,
relatedOriginChecksEnabled: boolean,
fetchFn?: typeof fetch,
): Promise<boolean> {
// Classic WebAuthn validation: rpId must be a registrable domain suffix of the origin
const classicMatch = isValidRpIdInternal(rpId, origin);
if (classicMatch) {
return true;
}
if (!relatedOriginChecksEnabled) {
return false;
}
// Fall back to Related Origin Requests (ROR) validation
return await isAllowedByRor(rpId, origin, fetchFn);
}

View File

@@ -71,6 +71,8 @@ describe("FidoAuthenticatorService", () => {
isValidRpId = jest.spyOn(DomainUtils, "isValidRpId");
configService.getFeatureFlag$.mockReturnValue(of(false));
client = new Fido2ClientService(
authenticator,
configService,
@@ -186,7 +188,7 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
// `params` actually has a valid rp.id, but we're mocking the function to return false
isValidRpId.mockReturnValue(false);
isValidRpId.mockResolvedValue(false);
const result = async () => await client.createCredential(params, windowReference);
@@ -459,7 +461,7 @@ describe("FidoAuthenticatorService", () => {
const params = createParams();
authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult());
// `params` actually has a valid rp.id, but we're mocking the function to return false
isValidRpId.mockReturnValue(false);
isValidRpId.mockResolvedValue(false);
const result = async () => await client.assertCredential(params, windowReference);

View File

@@ -3,6 +3,8 @@
import { firstValueFrom, Subscription } from "rxjs";
import { parse } from "tldts";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
@@ -62,6 +64,9 @@ export class Fido2ClientService<
MAX: 600000,
},
};
protected readonly relatedOriginChecksEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.WebAuthnRelatedOrigins,
);
constructor(
private authenticator: Fido2AuthenticatorService<ParentWindowReference>,
@@ -142,7 +147,13 @@ export class Fido2ClientService<
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
}
if (!isValidRpId(params.rp.id, params.origin)) {
if (
!(await isValidRpId(
params.rp.id,
params.origin,
await firstValueFrom(this.relatedOriginChecksEnabled$),
))
) {
this.logService?.warning(
`[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}`,
);
@@ -281,7 +292,13 @@ export class Fido2ClientService<
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
}
if (!isValidRpId(params.rpId, params.origin)) {
if (
!(await isValidRpId(
params.rpId,
params.origin,
await firstValueFrom(this.relatedOriginChecksEnabled$),
))
) {
this.logService?.warning(
`[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}`,
);

View File

@@ -47,6 +47,12 @@ export class Attachment extends Domain {
if (this.key != null) {
view.key = await this.decryptAttachmentKey(decryptionKey);
view.encryptedKey = this.key; // Keep the encrypted key for the view
// When the attachment key couldn't be decrypted, mark a decryption error
// The file won't be able to be downloaded in these cases
if (!view.key) {
view.hasDecryptionError = true;
}
}
return view;

View File

@@ -2,7 +2,7 @@ import { Jsonify } from "type-fest";
import { AttachmentView as SdkAttachmentView } from "@bitwarden/sdk-internal";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { DECRYPT_ERROR, EncString } from "../../../key-management/crypto/models/enc-string";
import { View } from "../../../models/view/view";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { Attachment } from "../domain/attachment";
@@ -18,6 +18,7 @@ export class AttachmentView implements View {
* The SDK returns an encrypted key for the attachment.
*/
encryptedKey: EncString | undefined;
private _hasDecryptionError?: boolean;
constructor(a?: Attachment) {
if (!a) {
@@ -41,6 +42,14 @@ export class AttachmentView implements View {
return 0;
}
get hasDecryptionError(): boolean {
return this._hasDecryptionError || this.fileName === DECRYPT_ERROR;
}
set hasDecryptionError(value: boolean) {
this._hasDecryptionError = value;
}
static fromJSON(obj: Partial<Jsonify<AttachmentView>>): AttachmentView {
const key = obj.key == null ? null : SymmetricCryptoKey.fromJSON(obj.key);
@@ -76,7 +85,10 @@ export class AttachmentView implements View {
/**
* Converts the SDK AttachmentView to a AttachmentView.
*/
static fromSdkAttachmentView(obj: SdkAttachmentView): AttachmentView | undefined {
static fromSdkAttachmentView(
obj: SdkAttachmentView,
failure = false,
): AttachmentView | undefined {
if (!obj) {
return undefined;
}
@@ -90,6 +102,7 @@ export class AttachmentView implements View {
// TODO: PM-23005 - Temporary field, should be removed when encrypted migration is complete
view.key = obj.decryptedKey ? SymmetricCryptoKey.fromString(obj.decryptedKey) : undefined;
view.encryptedKey = obj.key ? new EncString(obj.key) : undefined;
view._hasDecryptionError = failure;
return view;
}

View File

@@ -280,6 +280,17 @@ export class CipherView implements View, InitializerMetadata {
return undefined;
}
const attachments = obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? [];
if (obj.attachmentDecryptionFailures?.length) {
obj.attachmentDecryptionFailures.forEach((attachment) => {
const attachmentView = AttachmentView.fromSdkAttachmentView(attachment, true);
if (attachmentView) {
attachments.push(attachmentView);
}
});
}
const cipherView = new CipherView();
cipherView.id = uuidAsString(obj.id);
cipherView.organizationId = uuidAsString(obj.organizationId);
@@ -295,8 +306,7 @@ export class CipherView implements View, InitializerMetadata {
cipherView.edit = obj.edit;
cipherView.viewPassword = obj.viewPassword;
cipherView.localData = fromSdkLocalData(obj.localData);
cipherView.attachments =
obj.attachments?.map((a) => AttachmentView.fromSdkAttachmentView(a)!) ?? [];
cipherView.attachments = attachments;
cipherView.fields = obj.fields?.map((f) => FieldView.fromSdkFieldView(f)!) ?? [];
cipherView.passwordHistory =
obj.passwordHistory?.map((ph) => PasswordHistoryView.fromSdkPasswordHistoryView(ph)!) ?? [];

View File

@@ -0,0 +1,3 @@
@if (type() === "status" || content()) {
<span [class]="containerClasses()">{{ content() }}</span>
}

View File

@@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
export type BerryVariant =
| "primary"
| "subtle"
| "success"
| "warning"
| "danger"
| "accentPrimary"
| "contrast";
/**
* The berry component is a compact visual indicator used to display short,
* supplemental status information about another element,
* like a navigation item, button, or icon button.
* They draw users attention to status changes or new notifications.
*
* > `NOTE:` The maximum displayed value is 999. If the value is over 999, a “+” character is appended to indicate more.
*/
@Component({
selector: "bit-berry",
templateUrl: "berry.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BerryComponent {
protected readonly variant = input<BerryVariant>("primary");
protected readonly value = input<number>();
protected readonly type = input<"status" | "count">("count");
protected readonly content = computed(() => {
const value = this.value();
const type = this.type();
if (type === "status" || !value || value < 0) {
return undefined;
}
return value > 999 ? "999+" : `${value}`;
});
protected readonly textColor = computed(() => {
return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white";
});
protected readonly padding = computed(() => {
return (this.value()?.toString().length ?? 0) > 2 ? "tw-px-1.5 tw-py-0.5" : "";
});
protected readonly containerClasses = computed(() => {
const baseClasses = [
"tw-inline-flex",
"tw-items-center",
"tw-justify-center",
"tw-align-middle",
"tw-text-xxs",
"tw-rounded-full",
];
const typeClasses = {
status: ["tw-h-2", "tw-w-2"],
count: ["tw-h-4", "tw-min-w-4", this.padding()],
};
const variantClass = {
primary: "tw-bg-bg-brand",
subtle: "tw-bg-bg-contrast",
success: "tw-bg-bg-success",
warning: "tw-bg-bg-warning",
danger: "tw-bg-bg-danger",
accentPrimary: "tw-bg-fg-accent-primary-strong",
contrast: "tw-bg-bg-white",
};
return [
...baseClasses,
...typeClasses[this.type()],
variantClass[this.variant()],
this.textColor(),
].join(" ");
});
}

View File

@@ -0,0 +1,48 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./berry.stories";
<Meta of={stories} />
```ts
import { BerryComponent } from "@bitwarden/components";
```
<Title />
<Description />
<Primary />
<Controls />
## Usage
### Status
- Use a status berry to indicate a new notification of a status change that is not related to a
specific count.
<Canvas of={stories.statusType} />
### Count
- Use a count berry with text to indicate item count information for multiple new notifications.
<Canvas of={stories.countType} />
### All Variants
<Canvas of={stories.AllVariants} />
## Count Behavior
- Counts of **1-99**: Display in a compact circular shape
- Counts of **100-999**: Display in a pill shape with padding
- Counts **over 999**: Display as "999+" to prevent overflow
## Accessibility
- Use berries as **supplemental visual indicators** alongside descriptive text
- Ensure sufficient color contrast with surrounding elements
- For screen readers, provide appropriate labels on parent elements that describe the berry's
meaning
- Berries are decorative; important information should not rely solely on the berry color

View File

@@ -0,0 +1,167 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { BerryComponent } from "./berry.component";
export default {
title: "Component Library/Berry",
component: BerryComponent,
decorators: [
moduleMetadata({
imports: [BerryComponent],
}),
],
args: {
type: "count",
variant: "primary",
value: 5,
},
argTypes: {
type: {
control: "select",
options: ["status", "count"],
description: "The type of the berry, which determines its size and content",
table: {
category: "Inputs",
type: { summary: '"status" | "count"' },
defaultValue: { summary: '"count"' },
},
},
variant: {
control: "select",
options: ["primary", "subtle", "success", "warning", "danger", "accentPrimary", "contrast"],
description: "The visual style variant of the berry",
table: {
category: "Inputs",
type: { summary: "BerryVariant" },
defaultValue: { summary: "primary" },
},
},
value: {
control: "number",
description:
"Optional value to display for berries with type 'count'. Maximum displayed is 999, values above show '999+'. If undefined, a small small berry is shown. If 0 or negative, the berry is hidden.",
table: {
category: "Inputs",
type: { summary: "number | undefined" },
defaultValue: { summary: "undefined" },
},
},
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/rKUVGKb7Kw3d6YGoQl6Ho7/Tailwind-Component-Library?node-id=38367-199458&p=f&m=dev",
},
},
} as Meta<BerryComponent>;
type Story = StoryObj<BerryComponent>;
export const Primary: Story = {
render: (args) => ({
props: args,
template: `<bit-berry [type]="type" [variant]="variant" [value]="value"></bit-berry>`,
}),
};
export const statusType: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-flex tw-items-center tw-gap-4">
<bit-berry [type]="'status'" variant="primary"></bit-berry>
<bit-berry [type]="'status'" variant="subtle"></bit-berry>
<bit-berry [type]="'status'" variant="success"></bit-berry>
<bit-berry [type]="'status'" variant="warning"></bit-berry>
<bit-berry [type]="'status'" variant="danger"></bit-berry>
<bit-berry [type]="'status'" variant="accentPrimary"></bit-berry>
<bit-berry [type]="'status'" variant="contrast"></bit-berry>
</div>
`,
}),
};
export const countType: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-flex tw-items-center tw-gap-4">
<bit-berry [value]="5"></bit-berry>
<bit-berry [value]="50"></bit-berry>
<bit-berry [value]="500"></bit-berry>
<bit-berry [value]="5000"></bit-berry>
</div>
`,
}),
};
export const AllVariants: Story = {
render: () => ({
template: `
<div class="tw-flex tw-flex-col tw-gap-4">
<div class="tw-flex tw-items-center tw-gap-4">
<span class="tw-w-20">Primary:</span>
<bit-berry type="status" variant="primary"></bit-berry>
<bit-berry variant="primary" [value]="5"></bit-berry>
<bit-berry variant="primary" [value]="50"></bit-berry>
<bit-berry variant="primary" [value]="500"></bit-berry>
<bit-berry variant="primary" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4">
<span class="tw-w-20">Subtle:</span>
<bit-berry type="status"variant="subtle"></bit-berry>
<bit-berry variant="subtle" [value]="5"></bit-berry>
<bit-berry variant="subtle" [value]="50"></bit-berry>
<bit-berry variant="subtle" [value]="500"></bit-berry>
<bit-berry variant="subtle" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4">
<span class="tw-w-20">Success:</span>
<bit-berry type="status" variant="success"></bit-berry>
<bit-berry variant="success" [value]="5"></bit-berry>
<bit-berry variant="success" [value]="50"></bit-berry>
<bit-berry variant="success" [value]="500"></bit-berry>
<bit-berry variant="success" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4">
<span class="tw-w-20">Warning:</span>
<bit-berry type="status" variant="warning"></bit-berry>
<bit-berry variant="warning" [value]="5"></bit-berry>
<bit-berry variant="warning" [value]="50"></bit-berry>
<bit-berry variant="warning" [value]="500"></bit-berry>
<bit-berry variant="warning" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4">
<span class="tw-w-20">Danger:</span>
<bit-berry type="status" variant="danger"></bit-berry>
<bit-berry variant="danger" [value]="5"></bit-berry>
<bit-berry variant="danger" [value]="50"></bit-berry>
<bit-berry variant="danger" [value]="500"></bit-berry>
<bit-berry variant="danger" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4">
<span class="tw-w-20">Accent primary:</span>
<bit-berry type="status" variant="accentPrimary"></bit-berry>
<bit-berry variant="accentPrimary" [value]="5"></bit-berry>
<bit-berry variant="accentPrimary" [value]="50"></bit-berry>
<bit-berry variant="accentPrimary" [value]="500"></bit-berry>
<bit-berry variant="accentPrimary" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark">
<span class="tw-w-20 tw-text-fg-white">Contrast:</span>
<bit-berry type="status" variant="contrast"></bit-berry>
<bit-berry variant="contrast" [value]="5"></bit-berry>
<bit-berry variant="contrast" [value]="50"></bit-berry>
<bit-berry variant="contrast" [value]="500"></bit-berry>
<bit-berry variant="contrast" [value]="5000"></bit-berry>
</div>
</div>
`,
}),
};

View File

@@ -0,0 +1 @@
export * from "./berry.component";

View File

@@ -25,6 +25,35 @@ interruptive if overused.
For non-blocking, supplementary content, open dialogs as a
[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`).
### Closing Drawers on Navigation
When using drawers, you may want to close them automatically when the user navigates to another page
to prevent the drawer from persisting across route changes. To implement this functionality:
1. Store a reference to the dialog when opening it
2. Implement `OnDestroy` and close the dialog in `ngOnDestroy`
```ts
import { Component, OnDestroy } from "@angular/core";
import { DialogRef } from "@bitwarden/components";
export class MyComponent implements OnDestroy {
private myDialogRef: DialogRef;
ngOnDestroy() {
this.myDialogRef?.close();
}
openDrawer() {
this.myDialogRef = this.dialogService.open(MyDialogComponent, {
// dialog options
});
}
}
```
This ensures drawers are closed when the component is destroyed during navigation.
## Placement
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to

View File

@@ -7,6 +7,7 @@ export * from "./avatar";
export * from "./badge-list";
export * from "./badge";
export * from "./banner";
export * from "./berry";
export * from "./breadcrumbs";
export * from "./button";
export * from "./callout";

View File

@@ -317,6 +317,7 @@ module.exports = {
base: ["1rem", "150%"],
sm: ["0.875rem", "150%"],
xs: [".75rem", "150%"],
xxs: [".5rem", "150%"],
},
container: {
"@5xl": "1100px",

View File

@@ -4,52 +4,76 @@
<ul aria-labelledby="attachments" class="tw-list-none tw-pl-0">
@for (attachment of attachments; track attachment.id) {
<li>
<bit-item>
<bit-item-content>
<span data-testid="file-name" [title]="attachment.fileName">{{
attachment.fileName
}}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
<i
*ngIf="attachment.key == null"
slot="default-trailing"
class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted"
[appA11yTitle]="'fixEncryptionTooltip' | i18n"
></i>
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
@if (attachment.key != null) {
<app-download-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipher]="cipher()"
[attachment]="attachment"
></app-download-attachment>
} @else {
<button
[bitAction]="fixOldAttachment(attachment)"
bitButton
buttonType="primary"
size="small"
type="button"
>
{{ "fixEncryption" | i18n }}
</button>
@if (!attachment.hasDecryptionError) {
<bit-item>
<bit-item-content>
<span data-testid="file-name" [title]="attachment.fileName">
{{ attachment.fileName }}
</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
@if (attachment.key == null) {
<i
slot="default-trailing"
class="bwi bwi-exclamation-triangle bwi-sm tw-text-muted"
[appA11yTitle]="'fixEncryptionTooltip' | i18n"
></i>
}
</bit-item-action>
@if (cipher().edit) {
</bit-item-content>
<ng-container slot="end">
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
@if (attachment.key != null) {
<app-download-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipher]="cipher()"
[attachment]="attachment"
></app-download-attachment>
} @else {
<button
[bitAction]="fixOldAttachment(attachment)"
bitButton
buttonType="primary"
size="small"
type="button"
>
{{ "fixEncryption" | i18n }}
</button>
}
</bit-item-action>
}
</ng-container>
</bit-item>
@if (cipher().edit) {
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
}
</ng-container>
</bit-item>
} @else {
<bit-item>
<bit-item-content>
<span data-testid="file-name" [title]="'errorCannotDecrypt' | i18n">
{{ "errorCannotDecrypt" | i18n }}
</span>
</bit-item-content>
<ng-container slot="end">
@if (cipher().edit) {
<bit-item-action>
<app-delete-attachment
[admin]="admin() && organization()?.canEditAllCiphers"
[cipherId]="cipher().id"
[attachment]="attachment"
(onDeletionSuccess)="removeAttachment(attachment)"
></app-delete-attachment>
</bit-item-action>
}
</ng-container>
</bit-item>
}
</li>
}
</ul>

View File

@@ -173,7 +173,7 @@ describe("CipherAttachmentsComponent", () => {
const fileSize = fixture.debugElement.query(By.css('[data-testid="file-size"]'));
expect(fileName.nativeElement.textContent.trim()).toEqual(attachment.fileName);
expect(fileSize.nativeElement.textContent).toEqual(attachment.sizeName);
expect(fileSize.nativeElement.textContent.trim()).toEqual(attachment.sizeName);
});
describe("bitSubmit", () => {

View File

@@ -5,8 +5,12 @@
<bit-item-group>
<bit-item *ngFor="let attachment of cipher.attachments">
<bit-item-content>
<span data-testid="file-name" [title]="attachment.fileName">{{ attachment.fileName }}</span>
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
<span data-testid="file-name" [title]="getAttachmentFileName(attachment)">
{{ getAttachmentFileName(attachment) }}
</span>
@if (!attachment.hasDecryptionError) {
<span slot="secondary" data-testid="file-size">{{ attachment.sizeName }}</span>
}
</bit-item-content>
<ng-container slot="end">
<bit-item-action class="tw-pr-4 [@media(min-width:650px)]:tw-pr-6">

View File

@@ -8,9 +8,11 @@ import { NEVER, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { EmergencyAccessId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
ItemModule,
@@ -59,6 +61,7 @@ export class AttachmentsV2ViewComponent {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private stateProvider: StateProvider,
private accountService: AccountService,
private i18nService: I18nService,
) {
this.subscribeToHasPremiumCheck();
this.subscribeToOrgKey();
@@ -89,4 +92,12 @@ export class AttachmentsV2ViewComponent {
}
});
}
getAttachmentFileName(attachment: AttachmentView): string {
if (attachment.hasDecryptionError) {
return this.i18nService.t("errorCannotDecrypt");
}
return attachment.fileName ?? "";
}
}

View File

@@ -36,12 +36,11 @@ describe("DownloadAttachmentComponent", () => {
.mockResolvedValue({ url: "https://www.downloadattachement.com" });
const download = jest.fn();
const attachment = {
id: "222-3333-4444",
url: "https://www.attachment.com",
fileName: "attachment-filename",
size: "1234",
} as AttachmentView;
const attachment = new AttachmentView();
attachment.id = "222-3333-4444";
attachment.url = "https://www.attachment.com";
attachment.fileName = "attachment-filename";
attachment.size = "1234";
const cipherView = {
id: "5555-444-3333",
@@ -123,7 +122,12 @@ describe("DownloadAttachmentComponent", () => {
});
it("hides download button when the attachment has decryption failure", () => {
const decryptFailureAttachment = { ...attachment, fileName: DECRYPT_ERROR };
const decryptFailureAttachment = new AttachmentView();
decryptFailureAttachment.id = attachment.id;
decryptFailureAttachment.url = attachment.url;
decryptFailureAttachment.size = attachment.size;
decryptFailureAttachment.fileName = DECRYPT_ERROR;
fixture.componentRef.setInput("attachment", decryptFailureAttachment);
fixture.detectChanges();

View File

@@ -4,7 +4,6 @@ import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { DECRYPT_ERROR } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -46,9 +45,7 @@ export class DownloadAttachmentComponent {
private cipherService: CipherService,
) {}
protected readonly isDecryptionFailure = computed(
() => this.attachment().fileName === DECRYPT_ERROR,
);
protected readonly isDecryptionFailure = computed(() => this.attachment().hasDecryptionError);
/** Download the attachment */
download = async () => {

View File

@@ -4,12 +4,13 @@
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="addOptions"
[bitMenuTriggerFor]="isOnlyCollectionCreation() ? null : addOptions"
(click)="handleButtonClick()"
id="newItemDropdown"
[appA11yTitle]="'new' | i18n"
[appA11yTitle]="getButtonLabel() | i18n"
>
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ "new" | i18n }}
{{ getButtonLabel() | i18n }}
</button>
<bit-menu #addOptions aria-labelledby="newItemDropdown">
@for (item of cipherMenuItems$ | async; track item.type) {

View File

@@ -1,6 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component, input, output } from "@angular/core";
import { map, shareReplay } from "rxjs";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, map, shareReplay } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -38,10 +39,18 @@ export class NewCipherMenuComponent {
/**
* Returns an observable that emits the cipher menu items, filtered by the restricted types.
*/
cipherMenuItems$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => {
cipherMenuItems$ = combineLatest([
this.restrictedItemTypesService.restricted$,
toObservable(this.canCreateCipher),
toObservable(this.canCreateSshKey),
]).pipe(
map(([restrictedTypes, canCreateCipher, canCreateSshKey]) => {
// If user cannot create ciphers at all, return empty array
if (!canCreateCipher) {
return [];
}
return CIPHER_MENU_ITEMS.filter((item) => {
if (!this.canCreateSshKey() && item.type === CipherType.SshKey) {
if (!canCreateSshKey && item.type === CipherType.SshKey) {
return false;
}
return !restrictedTypes.some((restrictedType) => restrictedType.cipherType === item.type);
@@ -49,4 +58,40 @@ export class NewCipherMenuComponent {
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**
* Returns the appropriate button label based on what can be created.
* If only collections can be created (no ciphers or folders), show "New Collection".
* Otherwise, show "New".
*/
protected getButtonLabel(): string {
const canCreateCipher = this.canCreateCipher();
const canCreateFolder = this.canCreateFolder();
const canCreateCollection = this.canCreateCollection();
// If only collections can be created, be specific
if (!canCreateCipher && !canCreateFolder && canCreateCollection) {
return "newCollection";
}
return "new";
}
/**
* Returns true if only collections can be created (no other options).
* When this is true, the button should directly create a collection instead of showing a dropdown.
*/
protected isOnlyCollectionCreation(): boolean {
return !this.canCreateCipher() && !this.canCreateFolder() && this.canCreateCollection();
}
/**
* Handles the button click. If only collections can be created, directly emit the collection event.
* Otherwise, the menu trigger will handle opening the dropdown.
*/
protected handleButtonClick(): void {
if (this.isOnlyCollectionCreation()) {
this.collectionAdded.emit();
}
}
}

View File

@@ -1,9 +1,9 @@
<bit-simple-dialog>
<i
<bit-icon
bitDialogIcon
class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl"
aria-hidden="true"
></i>
name="bwi-exclamation-triangle"
class="tw-text-warning tw-text-3xl"
></bit-icon>
<span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span>
@@ -25,9 +25,9 @@
{{ "goBack" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm">
{{ "howToManageMyVault" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
<bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon>
</a>
</ng-container>
</bit-simple-dialog>

View File

@@ -10,6 +10,7 @@ import {
DialogService,
ButtonModule,
DialogModule,
IconModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
@@ -35,7 +36,7 @@ export type LeaveConfirmationDialogResultType = UnionOfValues<typeof LeaveConfir
@Component({
templateUrl: "./leave-confirmation-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule],
})
export class LeaveConfirmationDialogComponent {
private readonly params = inject<LeaveConfirmationDialogParams>(DIALOG_DATA);

View File

@@ -14,9 +14,9 @@
{{ "declineAndLeave" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-self-center tw-text-sm">
{{ "whyAmISeeingThis" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
<bit-icon name="bwi-external-link" class="tw-ml-1"></bit-icon>
</a>
</ng-container>
</bit-simple-dialog>

View File

@@ -10,6 +10,7 @@ import {
DialogService,
ButtonModule,
DialogModule,
IconModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
@@ -35,7 +36,7 @@ export type TransferItemsDialogResultType = UnionOfValues<typeof TransferItemsDi
@Component({
templateUrl: "./transfer-items-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule],
imports: [ButtonModule, DialogModule, IconModule, LinkModule, TypographyModule, JslibModule],
})
export class TransferItemsDialogComponent {
private readonly params = inject<TransferItemsDialogParams>(DIALOG_DATA);

View File

@@ -938,4 +938,142 @@ describe("DefaultVaultItemsTransferService", () => {
expect(transferInProgressValues).toEqual([false, true, false]);
});
});
describe("enforcementInFlight", () => {
const policy = {
organizationId: organizationId,
revisionDate: new Date("2024-01-01"),
} as Policy;
const organization = {
id: organizationId,
name: "Test Org",
} as Organization;
const personalCiphers = [{ id: "cipher-1" } as CipherView];
const defaultCollection = {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView;
beforeEach(() => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
mockPolicyService.policiesByType$.mockReturnValue(of([policy]));
mockOrganizationService.organizations$.mockReturnValue(of([organization]));
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(defaultCollection));
mockSyncService.fullSync.mockResolvedValue(true);
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
});
it("prevents re-entry when enforcement is already in flight", async () => {
// Create a dialog that resolves after a delay
const delayedSubject = new Subject<any>();
const delayedDialog = {
closed: delayedSubject.asObservable(),
close: jest.fn(),
} as unknown as DialogRef<any>;
mockDialogService.open.mockReturnValue(delayedDialog);
// Start first call (won't complete immediately)
const firstCall = service.enforceOrganizationDataOwnership(userId);
// Flush microtasks to allow first call to set enforcementInFlight
await Promise.resolve();
// Second call should return immediately without opening dialog
await service.enforceOrganizationDataOwnership(userId);
// Verify re-entry was prevented - only the first call should proceed
expect(mockDialogService.open).toHaveBeenCalledTimes(1);
expect(mockPolicyService.policiesByType$).toHaveBeenCalledTimes(1);
// Clean up - resolve the first call's dialog
delayedSubject.next(TransferItemsDialogResult.Declined);
delayedSubject.complete();
// Mock the leave dialog
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(LeaveConfirmationDialogResult.Confirmed),
);
await firstCall;
});
it("allows subsequent calls after user declines and leaves", async () => {
// First call: user declines and confirms leaving
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
await service.enforceOrganizationDataOwnership(userId);
// Reset mocks for second call
mockDialogService.open.mockClear();
// Second call: user accepts transfer
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Second call should proceed (dialog opened again)
expect(mockDialogService.open).toHaveBeenCalledTimes(1);
});
it("allows subsequent calls after successful transfer", async () => {
// First call: user accepts transfer
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Reset mocks for second call
mockDialogService.open.mockClear();
mockCipherService.shareManyWithServer.mockClear();
// Second call should be allowed (though no migration needed after first transfer)
// Set up scenario where migration is needed again
mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-2" } as CipherView]));
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Second call should proceed (dialog opened again)
expect(mockDialogService.open).toHaveBeenCalledTimes(1);
});
it("allows subsequent calls after transfer fails with error", async () => {
// First call: transfer fails
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
await service.enforceOrganizationDataOwnership(userId);
// Reset mocks for second call
mockDialogService.open.mockClear();
mockCipherService.shareManyWithServer.mockClear();
// Second call: user accepts transfer successfully
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Second call should proceed (dialog opened again)
expect(mockDialogService.open).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -62,6 +62,12 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
transferInProgress$ = this._transferInProgressSubject.asObservable();
/**
* Only a single enforcement should be allowed to run at a time to prevent multiple dialogs
* or multiple simultaneous transfers.
*/
private enforcementInFlight: boolean = false;
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
map(
@@ -142,7 +148,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
FeatureFlag.MigrateMyVaultToMyItems,
);
if (!featureEnabled) {
if (!featureEnabled || this.enforcementInFlight) {
return;
}
@@ -160,6 +166,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
return;
}
this.enforcementInFlight = true;
const userAcceptedTransfer = await this.promptUserForTransfer(
migrationInfo.enforcingOrganization.name,
);
@@ -179,6 +187,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
);
// Sync to reflect organization removal
await this.syncService.fullSync(true);
this.enforcementInFlight = false;
return;
}
@@ -208,6 +217,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
} finally {
this.enforcementInFlight = false;
}
}