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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)!) ?? [];
|
||||
|
||||
3
libs/components/src/berry/berry.component.html
Normal file
3
libs/components/src/berry/berry.component.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (type() === "status" || content()) {
|
||||
<span [class]="containerClasses()">{{ content() }}</span>
|
||||
}
|
||||
80
libs/components/src/berry/berry.component.ts
Normal file
80
libs/components/src/berry/berry.component.ts
Normal 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(" ");
|
||||
});
|
||||
}
|
||||
48
libs/components/src/berry/berry.mdx
Normal file
48
libs/components/src/berry/berry.mdx
Normal 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
|
||||
167
libs/components/src/berry/berry.stories.ts
Normal file
167
libs/components/src/berry/berry.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
1
libs/components/src/berry/index.ts
Normal file
1
libs/components/src/berry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./berry.component";
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -317,6 +317,7 @@ module.exports = {
|
||||
base: ["1rem", "150%"],
|
||||
sm: ["0.875rem", "150%"],
|
||||
xs: [".75rem", "150%"],
|
||||
xxs: [".5rem", "150%"],
|
||||
},
|
||||
container: {
|
||||
"@5xl": "1100px",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user