diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index e730122d1c4..03c37dfaf30 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2479,6 +2479,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoreItem": { "message": "Restore item" }, @@ -5676,6 +5679,9 @@ "extraWide": { "message": "Extra wide" }, + "narrow": { + "message": "Narrow" + }, "sshKeyWrongPassword": { "message": "The password you entered is incorrect." }, diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 61ed7a8ed08..257f7e9efd5 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,3 +1,5 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -23,7 +25,7 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: ((event: MessageEvent) => void) | null = null; + private messageEventListener: (event: MessageEvent) => void | null = null; private onDestroy = new EventTarget(); /** @@ -58,12 +60,6 @@ export class Messenger { this.broadcastChannel.addEventListener(this.messageEventListener); } - private stripMetadata({ SENDER, senderId, ...message }: MessageWithMetadata): Message { - void SENDER; - void senderId; - return message; - } - /** * Sends a request to the content script and returns the response. * AbortController signals will be forwarded to the content script. @@ -78,9 +74,7 @@ export class Messenger { try { const promise = new Promise((resolve) => { - localPort.onmessage = (event: MessageEvent) => { - resolve(this.stripMetadata(event.data)); - }; + localPort.onmessage = (event: MessageEvent) => resolve(event.data); }); const abortListener = () => @@ -135,9 +129,7 @@ export class Messenger { try { const handlerResponse = await this.handler(message, abortController); - if (handlerResponse !== undefined) { - port.postMessage({ ...handlerResponse, SENDER }); - } + port.postMessage({ ...handlerResponse, SENDER }); } catch (error) { port.postMessage({ SENDER, diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 18eb8e2baf8..25fcb9038d8 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1083,6 +1083,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100); } }); + + this.autofillOverlayContentService.refreshMenuLayerPosition(); } }; diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts index 746f5a1f8f7..c277b8d33f8 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -319,7 +319,10 @@ describe("PhishingDataService", () => { jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net"); - await service["_loadBlobToMemory"](); + // Trigger the load pipeline and allow async RxJS processing to complete + service["_loadBlobToMemory"](); + await flushPromises(); + const set = service["_webAddressesSet"] as Set; expect(set).toBeDefined(); expect(set.has("phish.com")).toBe(true); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 85e91b06a6b..7d5f04cc276 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -3,11 +3,15 @@ import { EMPTY, first, firstValueFrom, + from, + of, share, + takeUntil, startWith, Subject, switchMap, tap, + map, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -64,12 +68,20 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { + // While background scripts do not necessarily need destroying, + // processes in PhishingDataService are memory intensive. + // We are adding the destroy to guard against accidental leaks. + private _destroy$ = new Subject(); + private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY); // In-memory set loaded from blob for fast lookups without reading large storage repeatedly private _webAddressesSet: Set | null = null; + // Loading variables for web addresses set + // Triggers a load for _webAddressesSet + private _loadTrigger$ = new Subject(); // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours @@ -81,6 +93,10 @@ export class PhishingDataService { this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below tap((metaState) => { + // Initial loading of web addresses set if not already loaded + if (!this._webAddressesSet) { + this._loadBlobToMemory(); + } // Perform any updates in the background if needed void this._backgroundUpdate(metaState); }), @@ -90,6 +106,8 @@ export class PhishingDataService { }), ), ), + // Stop emitting when dispose() is called + takeUntil(this._destroy$), share(), ); @@ -109,7 +127,18 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); - void this._loadBlobToMemory(); + this._setupLoadPipeline(); + } + + dispose(): void { + // Signal all pipelines to stop and unsubscribe stored subscriptions + this._destroy$.next(); + this._destroy$.complete(); + + // Clear web addresses set from memory + if (this._webAddressesSet !== null) { + this._webAddressesSet = null; + } } /** @@ -269,7 +298,7 @@ export class PhishingDataService { } if (next.blob) { await this._phishingBlobState.update(() => next!.blob!); - await this._loadBlobToMemory(); + this._loadBlobToMemory(); } // Performance logging @@ -293,6 +322,47 @@ export class PhishingDataService { } } + // Sets up the load pipeline to load the blob into memory when triggered + private _setupLoadPipeline(): void { + this._loadTrigger$ + .pipe( + switchMap(() => + this._phishingBlobState.state$.pipe( + first(), + switchMap((blobBase64) => { + if (!blobBase64) { + return of(undefined); + } + // Note: _decompressString wraps a promise that cannot be aborted + // If performance improvements are needed, consider migrating to a cancellable approach + return from(this._decompressString(blobBase64)).pipe( + map((text) => { + const lines = text.split(/\r?\n/); + const newWebAddressesSet = new Set(lines); + this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); + this._webAddressesSet = new Set(newWebAddressesSet); + this.logService.info( + `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, + ); + }), + ); + }), + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Failed to load blob into memory", err); + return of(undefined); + }), + ), + ), + catchError((err: unknown) => { + this.logService.error("[PhishingDataService] Load pipeline failed", err); + return of(undefined); + }), + takeUntil(this._destroy$), + share(), + ) + .subscribe(); + } + // [FIXME] Move compression helpers to a shared utils library // to separate from phishing data service. // ------------------------- Blob and Compression Handling ------------------------- @@ -337,29 +407,9 @@ export class PhishingDataService { } } - // Try to load compressed newline blob into an in-memory Set for fast lookups - private async _loadBlobToMemory(): Promise { - this.logService.debug("[PhishingDataService] Loading data blob into memory..."); - try { - const blobBase64 = await firstValueFrom(this._phishingBlobState.state$); - if (!blobBase64) { - return; - } - - const text = await this._decompressString(blobBase64); - // Split and filter - const lines = text.split(/\r?\n/); - const newWebAddressesSet = new Set(lines); - - // Add test addresses - this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); - this._webAddressesSet = new Set(newWebAddressesSet); - this.logService.info( - `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, - ); - } catch (err) { - this.logService.error("[PhishingDataService] Failed to load blob into memory", err); - } + // Trigger a load of the blob into memory + private _loadBlobToMemory(): void { + this._loadTrigger$.next(); } private _uint8ToBase64Fallback(bytes: Uint8Array): string { const CHUNK_SIZE = 0x8000; // 32KB chunks diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index d90e872eef8..815007e1d4c 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -137,6 +137,9 @@ export class PhishingDetectionService { this._didInit = true; return () => { + // Dispose phishing data service resources + phishingDataService.dispose(); + initSub.unsubscribe(); this._didInit = false; diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts new file mode 100644 index 00000000000..75bd634b1fc --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -0,0 +1,375 @@ +import { ReadableStream as NodeReadableStream } from "stream/web"; + +import { mock, MockProxy } from "jest-mock-extended"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; + +describe("PhishingIndexedDbService", () => { + let service: PhishingIndexedDbService; + let logService: MockProxy; + + // Mock IndexedDB storage (keyed by URL for row-per-URL storage) + let mockStore: Map; + let mockObjectStore: any; + let mockTransaction: any; + let mockDb: any; + let mockOpenRequest: any; + + beforeEach(() => { + logService = mock(); + mockStore = new Map(); + + // Mock IDBObjectStore + mockObjectStore = { + put: jest.fn().mockImplementation((record: { url: string }) => { + const request = { + error: null as DOMException | null, + result: undefined as undefined, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + mockStore.set(record.url, record); + request.onsuccess?.(); + }, 0); + return request; + }), + get: jest.fn().mockImplementation((key: string) => { + const request = { + error: null as DOMException | null, + result: mockStore.get(key), + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + request.result = mockStore.get(key); + request.onsuccess?.(); + }, 0); + return request; + }), + clear: jest.fn().mockImplementation(() => { + const request = { + error: null as DOMException | null, + result: undefined as undefined, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + setTimeout(() => { + mockStore.clear(); + request.onsuccess?.(); + }, 0); + return request; + }), + openCursor: jest.fn().mockImplementation(() => { + const entries = Array.from(mockStore.entries()); + let index = 0; + const request = { + error: null as DOMException | null, + result: null as any, + onsuccess: null as ((e: any) => void) | null, + onerror: null as (() => void) | null, + }; + const advanceCursor = () => { + if (index < entries.length) { + const [, value] = entries[index]; + index++; + request.result = { + value, + continue: () => setTimeout(advanceCursor, 0), + }; + } else { + request.result = null; + } + request.onsuccess?.({ target: request }); + }; + setTimeout(advanceCursor, 0); + return request; + }), + }; + + // Mock IDBTransaction + mockTransaction = { + objectStore: jest.fn().mockReturnValue(mockObjectStore), + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + + // Trigger oncomplete after a tick + const originalObjectStore = mockTransaction.objectStore; + mockTransaction.objectStore = jest.fn().mockImplementation((...args: any[]) => { + setTimeout(() => mockTransaction.oncomplete?.(), 0); + return originalObjectStore(...args); + }); + + // Mock IDBDatabase + mockDb = { + transaction: jest.fn().mockReturnValue(mockTransaction), + close: jest.fn(), + objectStoreNames: { + contains: jest.fn().mockReturnValue(true), + }, + createObjectStore: jest.fn(), + }; + + // Mock IDBOpenDBRequest + mockOpenRequest = { + error: null as DOMException | null, + result: mockDb, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + onupgradeneeded: null as ((event: any) => void) | null, + }; + + // Mock indexedDB.open + const mockIndexedDB = { + open: jest.fn().mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onsuccess?.(); + }, 0); + return mockOpenRequest; + }), + }; + + global.indexedDB = mockIndexedDB as any; + + service = new PhishingIndexedDbService(logService); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete (global as any).indexedDB; + }); + + describe("saveUrls", () => { + it("stores URLs in IndexedDB and returns true", async () => { + const urls = ["https://phishing.com", "https://malware.net"]; + + const result = await service.saveUrls(urls); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite"); + expect(mockObjectStore.clear).toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it("handles empty array", async () => { + const result = await service.saveUrls([]); + + expect(result).toBe(true); + expect(mockObjectStore.clear).toHaveBeenCalled(); + }); + + it("trims whitespace from URLs", async () => { + const urls = [" https://example.com ", "\nhttps://test.org\n"]; + + await service.saveUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" }); + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" }); + }); + + it("skips empty lines", async () => { + const urls = ["https://example.com", "", " ", "https://test.org"]; + + await service.saveUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("handles duplicate URLs via upsert (keyPath deduplication)", async () => { + const urls = [ + "https://example.com", + "https://example.com", // duplicate + "https://test.org", + ]; + + const result = await service.saveUrls(urls); + + expect(result).toBe(true); + // put() is called 3 times, but mockStore (using Map with URL as key) + // only stores 2 unique entries - demonstrating upsert behavior + expect(mockObjectStore.put).toHaveBeenCalledTimes(3); + expect(mockStore.size).toBe(2); + }); + + it("logs error and returns false on failure", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.saveUrls(["https://test.com"]); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Save failed", + expect.any(Error), + ); + }); + }); + + describe("hasUrl", () => { + it("returns true for existing URL", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + + const result = await service.hasUrl("https://example.com"); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.get).toHaveBeenCalledWith("https://example.com"); + }); + + it("returns false for non-existing URL", async () => { + const result = await service.hasUrl("https://notfound.com"); + + expect(result).toBe(false); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.hasUrl("https://example.com"); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Check failed", + expect.any(Error), + ); + }); + }); + + describe("loadAllUrls", () => { + it("loads all URLs using cursor", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const result = await service.loadAllUrls(); + + expect(result).toContain("https://example.com"); + expect(result).toContain("https://test.org"); + expect(result.length).toBe(2); + }); + + it("returns empty array when no data exists", async () => { + const result = await service.loadAllUrls(); + + expect(result).toEqual([]); + }); + + it("returns empty array on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.loadAllUrls(); + + expect(result).toEqual([]); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Load failed", + expect.any(Error), + ); + }); + }); + + describe("saveUrlsFromStream", () => { + it("saves URLs from stream", async () => { + const content = "https://example.com\nhttps://test.org\nhttps://phishing.net"; + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(true); + expect(mockObjectStore.clear).toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(3); + }); + + it("handles chunked stream data", async () => { + const content = "https://url1.com\nhttps://url2.com"; + const encoder = new TextEncoder(); + const encoded = encoder.encode(content); + + // Split into multiple small chunks + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(encoded.slice(0, 5)); + controller.enqueue(encoded.slice(5, 10)); + controller.enqueue(encoded.slice(10)); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(true); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const stream = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("https://test.com")); + controller.close(); + }, + }) as unknown as ReadableStream; + + const result = await service.saveUrlsFromStream(stream); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Stream save failed", + expect.any(Error), + ); + }); + }); + + describe("database initialization", () => { + it("creates object store with keyPath on upgrade", async () => { + mockDb.objectStoreNames.contains.mockReturnValue(false); + + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onupgradeneeded?.({ target: mockOpenRequest }); + mockOpenRequest.onsuccess?.(); + }, 0); + return mockOpenRequest; + }); + + await service.hasUrl("https://test.com"); + + expect(mockDb.createObjectStore).toHaveBeenCalledWith("phishing-urls", { keyPath: "url" }); + }); + }); +}); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts new file mode 100644 index 00000000000..099839a38d9 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -0,0 +1,241 @@ +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +/** + * Record type for phishing URL storage in IndexedDB. + */ +type PhishingUrlRecord = { url: string }; + +/** + * IndexedDB storage service for phishing URLs. + * Stores URLs as individual rows. + */ +export class PhishingIndexedDbService { + private readonly DB_NAME = "bitwarden-phishing"; + private readonly STORE_NAME = "phishing-urls"; + private readonly DB_VERSION = 1; + private readonly CHUNK_SIZE = 50000; + + constructor(private logService: LogService) {} + + /** + * Opens the IndexedDB database, creating the object store if needed. + */ + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this.DB_NAME, this.DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.STORE_NAME)) { + db.createObjectStore(this.STORE_NAME, { keyPath: "url" }); + } + }; + }); + } + + /** + * Clears all records from the phishing URLs store. + */ + private clearStore(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const req = db.transaction(this.STORE_NAME, "readwrite").objectStore(this.STORE_NAME).clear(); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); + }); + } + + /** + * Saves an array of phishing URLs to IndexedDB. + * Atomically replaces all existing data. + * + * @param urls - Array of phishing URLs to save + * @returns `true` if save succeeded, `false` on error + */ + async saveUrls(urls: string[]): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.clearStore(db); + await this.saveChunked(db, urls); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Save failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Saves URLs in chunks to prevent transaction timeouts and UI freezes. + */ + private async saveChunked(db: IDBDatabase, urls: string[]): Promise { + const cleaned = urls.map((u) => u.trim()).filter(Boolean); + for (let i = 0; i < cleaned.length; i += this.CHUNK_SIZE) { + await this.saveChunk(db, cleaned.slice(i, i + this.CHUNK_SIZE)); + await new Promise((r) => setTimeout(r, 0)); // Yield to event loop + } + } + + /** + * Saves a single chunk of URLs in one transaction. + */ + private saveChunk(db: IDBDatabase, urls: string[]): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.STORE_NAME, "readwrite"); + const store = tx.objectStore(this.STORE_NAME); + for (const url of urls) { + store.put({ url } as PhishingUrlRecord); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Checks if a URL exists in the phishing database. + * + * @param url - The URL to check + * @returns `true` if URL exists, `false` if not found or on error + */ + async hasUrl(url: string): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.checkUrlExists(db, url); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Check failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs the actual URL existence check using index lookup. + */ + private checkUrlExists(db: IDBDatabase, url: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.STORE_NAME, "readonly"); + const req = tx.objectStore(this.STORE_NAME).get(url); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result !== undefined); + }); + } + + /** + * Loads all phishing URLs from IndexedDB. + * + * @returns Array of all stored URLs, or empty array on error + */ + async loadAllUrls(): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.getAllUrls(db); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Load failed", error); + return []; + } finally { + db?.close(); + } + } + + /** + * Iterates all records using a cursor. + */ + private getAllUrls(db: IDBDatabase): Promise { + return new Promise((resolve, reject) => { + const urls: string[] = []; + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + urls.push((cursor.value as PhishingUrlRecord).url); + cursor.continue(); + } else { + resolve(urls); + } + }; + }); + } + + /** + * Saves phishing URLs directly from a stream. + * Processes data incrementally to minimize memory usage. + * + * @param stream - ReadableStream of newline-delimited URLs + * @returns `true` if save succeeded, `false` on error + */ + async saveUrlsFromStream(stream: ReadableStream): Promise { + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.clearStore(db); + await this.processStream(db, stream); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Stream save failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Processes a stream of URL data, parsing lines and saving in chunks. + */ + private async processStream(db: IDBDatabase, stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let urls: string[] = []; + + try { + while (true) { + const { done, value } = await reader.read(); + + // Decode BEFORE done check; stream: !done flushes on final call + buffer += decoder.decode(value, { stream: !done }); + + if (done) { + // Split remaining buffer by newlines in case it contains multiple URLs + const lines = buffer.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + urls.push(trimmed); + } + } + if (urls.length > 0) { + await this.saveChunk(db, urls); + } + break; + } + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + urls.push(trimmed); + } + + if (urls.length >= this.CHUNK_SIZE) { + await this.saveChunk(db, urls); + urls = []; + await new Promise((r) => setTimeout(r, 0)); + } + } + } + } finally { + reader.releaseLock(); + } + } +} diff --git a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts index 6e2175e3a79..cb04f30b589 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -1,7 +1,7 @@ import { createChromeTabMock } from "../../autofill/spec/autofill-mocks"; import { BrowserApi } from "./browser-api"; -import BrowserPopupUtils from "./browser-popup-utils"; +import BrowserPopupUtils, { PopupWidthOptions } from "./browser-popup-utils"; describe("BrowserPopupUtils", () => { afterEach(() => { @@ -152,7 +152,7 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + width: PopupWidthOptions.default, }); jest.spyOn(BrowserApi, "createWindow").mockImplementation(); jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation(); @@ -168,7 +168,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -197,7 +197,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -214,7 +214,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -267,7 +267,7 @@ describe("BrowserPopupUtils", () => { expect(BrowserApi.createWindow).toHaveBeenCalledWith({ type: "popup", focused: true, - width: 380, + width: PopupWidthOptions.default, height: 630, left: 85, top: 190, @@ -290,7 +290,7 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + width: PopupWidthOptions.default, state: "fullscreen", }); jest @@ -321,7 +321,7 @@ describe("BrowserPopupUtils", () => { focused: false, alwaysOnTop: false, incognito: false, - width: 380, + width: PopupWidthOptions.default, state: "fullscreen", }); diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index 8343799d0eb..c8dba57e708 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -10,9 +10,9 @@ import { BrowserApi } from "./browser-api"; * Value represents width in pixels */ export const PopupWidthOptions = Object.freeze({ - default: 380, - wide: 480, - "extra-wide": 600, + default: 480, + wide: 600, + narrow: 380, }); type PopupWidthOptions = typeof PopupWidthOptions; diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index c6ffe1a6414..2e088b8161e 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -44,7 +44,7 @@ import { PopupTabNavigationComponent } from "./popup-tab-navigation.component"; @Component({ selector: "extension-container", template: ` -
+
`, @@ -678,7 +678,7 @@ export const WidthOptions: Story = { template: /* HTML */ `
Default:
-
+
Wide:
diff --git a/apps/browser/src/platform/popup/layout/popup-size.service.ts b/apps/browser/src/platform/popup/layout/popup-size.service.ts index 0e4aacb9a97..ff3f09d0d01 100644 --- a/apps/browser/src/platform/popup/layout/popup-size.service.ts +++ b/apps/browser/src/platform/popup/layout/popup-size.service.ts @@ -83,7 +83,7 @@ export class PopupSizeService { } const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default; - document.body.style.minWidth = `${pxWidth}px`; + document.body.style.width = `${pxWidth}px`; } /** diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 8fdae06e28a..66c9f655b05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -10,6 +10,7 @@ import { import { of } from "rxjs"; import { LockIcon, RegistrationCheckEmailIcon } from "@bitwarden/assets/svg"; +import { PopupWidthOptions } from "@bitwarden/browser/platform/browser/browser-popup-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; @@ -243,7 +244,12 @@ export const DefaultContentExample: Story = { }), parameters: { chromatic: { - viewports: [380, 1280], + viewports: [ + PopupWidthOptions.default, + PopupWidthOptions.narrow, + PopupWidthOptions.wide, + 1280, + ], }, }, }; diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css index f58950cc86a..0ef7b82bfed 100644 --- a/apps/browser/src/popup/scss/tailwind.css +++ b/apps/browser/src/popup/scss/tailwind.css @@ -60,7 +60,7 @@ } body { - width: 380px; + width: 480px; height: 100%; position: relative; min-height: inherit; @@ -84,9 +84,9 @@ animation: redraw 1s linear infinite; } - /** + /** * Text selection style: - * suppress user selection for most elements (to make it more app-like) + * suppress user selection for most elements (to make it more app-like) */ h1, h2, @@ -165,7 +165,7 @@ @apply tw-text-muted; } - /** + /** * Text selection style: * Set explicit selection styles (assumes primary accent color has sufficient * contrast against the background, so its inversion is also still readable) diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts index a28b8730109..93cc2cf248a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -91,10 +91,18 @@ describe("AutofillConfirmationDialogComponent", () => { jest.resetAllMocks(); }); - const findShowAll = (inFx?: ComponentFixture) => - (inFx || fixture).nativeElement.querySelector( - "button.tw-text-sm.tw-font-medium.tw-cursor-pointer", - ) as HTMLButtonElement | null; + const findShowAll = (inFx?: ComponentFixture) => { + // Find the button by its text content (showAll or showLess) + const buttons = Array.from( + (inFx || fixture).nativeElement.querySelectorAll("button"), + ) as HTMLButtonElement[]; + return ( + buttons.find((btn) => { + const text = btn.textContent?.trim() || ""; + return text === "showAll" || text === "showLess"; + }) || null + ); + }; it("normalizes currentUrl and savedUrls via Utils.getHostname", () => { expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0)); diff --git a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts index e6515ae7461..e02ccf25f3e 100644 --- a/apps/browser/src/vault/popup/settings/appearance-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/appearance-v2.component.ts @@ -79,7 +79,7 @@ export class AppearanceV2Component implements OnInit { protected readonly widthOptions: Option[] = [ { label: this.i18nService.t("default"), value: "default" }, { label: this.i18nService.t("wide"), value: "wide" }, - { label: this.i18nService.t("extraWide"), value: "extra-wide" }, + { label: this.i18nService.t("narrow"), value: "narrow" }, ]; constructor( diff --git a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts index bad6011b2d8..edebdab062f 100644 --- a/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/settings/trash-list-items-container/trash-list-items-container.component.ts @@ -115,15 +115,22 @@ export class TrashListItemsContainerComponent { } async restore(cipher: PopupCipherViewLike) { + let toastMessage; try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.cipherService.restoreWithServer(cipher.id as string, activeUserId); + if (cipher.archivedDate) { + toastMessage = this.i18nService.t("archivedItemRestored"); + } else { + toastMessage = this.i18nService.t("restoredItem"); + } + await this.router.navigate(["/trash"]); this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t("restoredItem"), + message: toastMessage, }); } catch (e) { this.logService.error(e); diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 226e9827e37..9760af69e8b 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -34,7 +34,7 @@ /Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/ /Library/Application Support/Vivaldi/NativeMessagingHosts/ /Library/Application Support/Zen/NativeMessagingHosts/ - /Library/Application Support/net.imput.helium + /Library/Application Support/net.imput.helium/NativeMessagingHosts/ diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f9947e16692..0ce98b8c62b 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2092,6 +2092,9 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, + "archivedItemRestored": { + "message": "Archived item restored" + }, "restoredItem": { "message": "Item restored" }, diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index c80e4e59ae4..399a0e7875d 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -173,16 +173,23 @@ export class ItemFooterComponent implements OnInit, OnChanges { } async restore(): Promise { + let toastMessage; if (!this.cipher.isDeleted) { return false; } + if (this.cipher.isArchived) { + toastMessage = this.i18nService.t("archivedItemRestored"); + } else { + toastMessage = this.i18nService.t("restoredItem"); + } + try { const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); await this.restoreCipher(activeUserId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("restoredItem"), + message: toastMessage, }); this.onRestore.emit(this.cipher); } catch (e) { @@ -239,6 +246,9 @@ export class ItemFooterComponent implements OnInit, OnChanges { // A user should always be able to unarchive an archived item this.showUnarchiveButton = - hasArchiveFlagEnabled && this.action === "view" && this.cipher.isArchived; + hasArchiveFlagEnabled && + this.action === "view" && + this.cipher.isArchived && + !this.cipher.isDeleted; } } diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index f92b433125c..20e6bc6d35b 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -611,7 +611,7 @@ export class VaultV2Component }); } - if (cipher.isArchived) { + if (cipher.isArchived && !cipher.isDeleted) { menu.push({ label: this.i18nService.t("unArchive"), click: async () => { diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index 05758a854c2..9a99a55b77b 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -31,81 +31,75 @@ - + - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - + + {{ "name" | i18n }} + {{ "owner" | i18n }} + - - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ r.subTitle }} - - - - - - - - {{ "instructions" | i18n }} - - -
-
+ + + + + + + {{ row.name }} + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
+ {{ row.subTitle }} + + + + + + + + {{ "instructions" | i18n }} + +
+
diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index 5dd11b59999..cc7537333ad 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -32,68 +32,63 @@ - + - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - + + {{ "name" | i18n }} + {{ "owner" | i18n }} - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
+ {{ row.subTitle }} + + + + +
-
+
diff --git a/apps/web/src/app/dirt/reports/reports-layout.component.html b/apps/web/src/app/dirt/reports/reports-layout.component.html index a27556a7aa9..0cb5d304a34 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.html +++ b/apps/web/src/app/dirt/reports/reports-layout.component.html @@ -2,8 +2,10 @@ diff --git a/apps/web/src/app/dirt/reports/reports-layout.component.ts b/apps/web/src/app/dirt/reports/reports-layout.component.ts index c2fbf858590..a6d84ccb037 100644 --- a/apps/web/src/app/dirt/reports/reports-layout.component.ts +++ b/apps/web/src/app/dirt/reports/reports-layout.component.ts @@ -1,6 +1,6 @@ -import { Component, OnDestroy } from "@angular/core"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { Subscription } from "rxjs"; import { filter } from "rxjs/operators"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -10,20 +10,20 @@ import { filter } from "rxjs/operators"; templateUrl: "reports-layout.component.html", standalone: false, }) -export class ReportsLayoutComponent implements OnDestroy { +export class ReportsLayoutComponent { homepage = true; - subscription: Subscription; constructor(router: Router) { - this.subscription = router.events - .pipe(filter((event) => event instanceof NavigationEnd)) - // eslint-disable-next-line rxjs-angular/prefer-takeuntil + const reportsHomeRoute = "/reports"; + + this.homepage = router.url === reportsHomeRoute; + router.events + .pipe( + takeUntilDestroyed(), + filter((event) => event instanceof NavigationEnd), + ) .subscribe((event) => { - this.homepage = (event as NavigationEnd).url == "/reports"; + this.homepage = (event as NavigationEnd).url == reportsHomeRoute; }); } - - ngOnDestroy(): void { - this.subscription?.unsubscribe(); - } } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 5b9f9db3e62..081829a8d83 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -171,37 +171,47 @@ - @if (!viewingOrgVault) { - } - - - - - + @if (!isDeleted && canEditCipher) { + + } + @if (showAttachments) { + + } + @if (showClone) { + + } + @if (showAssignToCollections) { + + } + @if (showEventLogs) { + + } @if (showArchiveButton) { @if (userCanArchive) { - + @if (bulkArchiveAllowed) { + + } - + @if (bulkUnarchiveAllowed) { + + } } @@ -61,7 +58,6 @@ @if (breadcrumb.route(); as route) { diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index e6de8ac8402..62f0d8b878f 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f import { AriaDisableDirective } from "../a11y"; import { ariaDisableElement } from "../utils"; -export type LinkType = "primary" | "secondary" | "contrast" | "light"; +export const LinkTypes = [ + "primary", + "secondary", + "contrast", + "light", + "default", + "subtle", + "success", + "warning", + "danger", +] as const; + +export type LinkType = (typeof LinkTypes)[number]; const linkStyles: Record = { - primary: [ - "!tw-text-primary-600", - "hover:!tw-text-primary-700", - "focus-visible:before:tw-ring-primary-600", - ], - secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"], + primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"], + default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"], + secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"], + light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"], + subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"], + success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"], + warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"], + danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"], contrast: [ - "!tw-text-contrast", - "hover:!tw-text-contrast", - "focus-visible:before:tw-ring-text-contrast", + "tw-text-fg-contrast", + "hover:tw-text-fg-contrast", + "focus-visible:before:tw-ring-fg-contrast", ], - light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"], }; const commonStyles = [ @@ -32,16 +45,18 @@ const commonStyles = [ "tw-rounded", "tw-transition", "tw-no-underline", + "tw-cursor-pointer", "hover:tw-underline", "hover:tw-decoration-1", "disabled:tw-no-underline", "disabled:tw-cursor-not-allowed", - "disabled:!tw-text-secondary-300", - "disabled:hover:!tw-text-secondary-300", + "disabled:!tw-text-fg-disabled", + "disabled:hover:!tw-text-fg-disabled", "disabled:hover:tw-no-underline", "focus-visible:tw-outline-none", "focus-visible:tw-underline", "focus-visible:tw-decoration-1", + "focus-visible:before:tw-ring-border-focus", // Workaround for html button tag not being able to be set to `display: inline` // and at the same time not being able to use `tw-ring-offset` because of box-shadow issue. @@ -63,14 +78,14 @@ const commonStyles = [ "focus-visible:tw-z-10", "aria-disabled:tw-no-underline", "aria-disabled:tw-pointer-events-none", - "aria-disabled:!tw-text-secondary-300", - "aria-disabled:hover:!tw-text-secondary-300", + "aria-disabled:!tw-text-fg-disabled", + "aria-disabled:hover:!tw-text-fg-disabled", "aria-disabled:hover:tw-no-underline", ]; @Directive() abstract class LinkDirective { - readonly linkType = input("primary"); + readonly linkType = input("default"); } /** diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx index 072e0dd84d8..4954effb6c0 100644 --- a/libs/components/src/link/link.mdx +++ b/libs/components/src/link/link.mdx @@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components"; You can use one of the following variants by providing it as the `linkType` input: -- `primary` - most common, uses brand color -- `secondary` - matches the main text color +- @deprecated `primary` => use `default` instead +- @deprecated `secondary` => use `subtle` instead +- `default` - most common, uses brand color +- `subtle` - matches the main text color - `contrast` - for high contrast against a dark background (or a light background in dark mode) - `light` - always a light color, even in dark mode +- `warning` - used in association with warning callouts/banners +- `success` - used in association with success callouts/banners +- `danger` - used in association with danger callouts/banners ## Sizes diff --git a/libs/components/src/link/link.stories.ts b/libs/components/src/link/link.stories.ts index ae91c9be108..d27c4f74332 100644 --- a/libs/components/src/link/link.stories.ts +++ b/libs/components/src/link/link.stories.ts @@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; -import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive"; +import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive"; import { LinkModule } from "./link.module"; export default { @@ -14,7 +14,7 @@ export default { ], argTypes: { linkType: { - options: ["primary", "secondary", "contrast"], + options: LinkTypes.map((type) => type), control: { type: "radio" }, }, }, @@ -30,48 +30,153 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` - (args)}>Your text here + `, }), + args: { + linkType: "primary", + }, + parameters: { + chromatic: { disableSnapshot: true }, + }, +}; + +export const AllVariations: Story = { + render: () => ({ + template: /*html*/ ` +
+
+ Primary +
+
+ Secondary +
+
+ Contrast +
+
+ Light +
+
+ Default +
+
+ Subtle +
+
+ Success +
+
+ Warning +
+
+ Danger +
+
+ `, + }), + parameters: { + controls: { + exclude: ["linkType"], + hideNoControlsWarning: true, + }, + }, }; export const InteractionStates: Story = { render: () => ({ template: /*html*/ ` -
+
+ -
+ -
+ - `, }), + parameters: { + controls: { + exclude: ["linkType"], + hideNoControlsWarning: true, + }, + }, }; export const Buttons: Story = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` -
+
@@ -100,9 +205,17 @@ export const Buttons: Story = { export const Anchors: StoryObj = { render: (args) => ({ - props: args, + props: { + linkType: args.linkType, + backgroundClass: + args.linkType === "contrast" + ? "tw-bg-bg-contrast" + : args.linkType === "light" + ? "tw-bg-bg-brand" + : "tw-bg-transparent", + }, template: /*html*/ ` -
+
@@ -138,18 +251,15 @@ export const Inline: Story = { `, }), - args: { - linkType: "primary", - }, }; -export const Disabled: Story = { +export const Inactive: Story = { render: (args) => ({ props: args, template: /*html*/ ` -
+
`, diff --git a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts index 23c95cafb8a..e54064f0c9d 100644 --- a/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts +++ b/libs/components/src/stories/kitchen-sink/components/kitchen-sink-form.component.ts @@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module"; A random password