1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 19:34:03 +00:00

Merge branch 'main' into PM-29919-Add-dropdown-to-select-email-verification-and-emails-field-to-Send-when-creating-or-editing-a-Send

This commit is contained in:
bmbitwarden
2026-01-21 18:21:55 -05:00
committed by GitHub
40 changed files with 1189 additions and 305 deletions

View File

@@ -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."
},

View File

@@ -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<MessageWithMetadata>) => void) | null = null;
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => 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<Message>((resolve) => {
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
resolve(this.stripMetadata(event.data));
};
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => 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,

View File

@@ -1083,6 +1083,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100);
}
});
this.autofillOverlayContentService.refreshMenuLayerPosition();
}
};

View File

@@ -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<string>;
expect(set).toBeDefined();
expect(set.has("phish.com")).toBe(true);

View File

@@ -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<string>(
/** 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<void>();
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<string> | null = null;
// Loading variables for web addresses set
// Triggers a load for _webAddressesSet
private _loadTrigger$ = new Subject<void>();
// 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<void> {
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

View File

@@ -137,6 +137,9 @@ export class PhishingDetectionService {
this._didInit = true;
return () => {
// Dispose phishing data service resources
phishingDataService.dispose();
initSub.unsubscribe();
this._didInit = false;

View File

@@ -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<LogService>;
// Mock IndexedDB storage (keyed by URL for row-per-URL storage)
let mockStore: Map<string, { url: string }>;
let mockObjectStore: any;
let mockTransaction: any;
let mockDb: any;
let mockOpenRequest: any;
beforeEach(() => {
logService = mock<LogService>();
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<Uint8Array>;
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<Uint8Array>;
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<Uint8Array>;
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" });
});
});
});

View File

@@ -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<IDBDatabase> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<string[]> {
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<string[]> {
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<IDBCursorWithValue | null>).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<Uint8Array>): Promise<boolean> {
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<Uint8Array>): Promise<void> {
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();
}
}
}

View File

@@ -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",
});

View File

@@ -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;

View File

@@ -44,7 +44,7 @@ import { PopupTabNavigationComponent } from "./popup-tab-navigation.component";
@Component({
selector: "extension-container",
template: `
<div class="tw-h-[640px] tw-w-[380px] tw-border tw-border-solid tw-border-secondary-300">
<div class="tw-h-[640px] tw-w-[480px] tw-border tw-border-solid tw-border-secondary-300">
<ng-content></ng-content>
</div>
`,
@@ -678,7 +678,7 @@ export const WidthOptions: Story = {
template: /* HTML */ `
<div class="tw-flex tw-flex-col tw-gap-4 tw-text-main">
<div>Default:</div>
<div class="tw-h-[640px] tw-w-[380px] tw-border tw-border-solid tw-border-secondary-300">
<div class="tw-h-[640px] tw-w-[480px] tw-border tw-border-solid tw-border-secondary-300">
<mock-vault-page></mock-vault-page>
</div>
<div>Wide:</div>

View File

@@ -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`;
}
/**

View File

@@ -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,
],
},
},
};

View File

@@ -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)

View File

@@ -91,10 +91,18 @@ describe("AutofillConfirmationDialogComponent", () => {
jest.resetAllMocks();
});
const findShowAll = (inFx?: ComponentFixture<AutofillConfirmationDialogComponent>) =>
(inFx || fixture).nativeElement.querySelector(
"button.tw-text-sm.tw-font-medium.tw-cursor-pointer",
) as HTMLButtonElement | null;
const findShowAll = (inFx?: ComponentFixture<AutofillConfirmationDialogComponent>) => {
// 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));

View File

@@ -79,7 +79,7 @@ export class AppearanceV2Component implements OnInit {
protected readonly widthOptions: Option<PopupWidthOption>[] = [
{ 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(

View File

@@ -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);

View File

@@ -34,7 +34,7 @@
<string>/Library/Application Support/Microsoft Edge Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Vivaldi/NativeMessagingHosts/</string>
<string>/Library/Application Support/Zen/NativeMessagingHosts/</string>
<string>/Library/Application Support/net.imput.helium</string>
<string>/Library/Application Support/net.imput.helium/NativeMessagingHosts/</string>
</array>
</dict>
</plist>

View File

@@ -2092,6 +2092,9 @@
"permanentlyDeletedItem": {
"message": "Item permanently deleted"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"restoredItem": {
"message": "Item restored"
},

View File

@@ -173,16 +173,23 @@ export class ItemFooterComponent implements OnInit, OnChanges {
}
async restore(): Promise<boolean> {
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;
}
}

View File

@@ -611,7 +611,7 @@ export class VaultV2Component<C extends CipherViewLike>
});
}
if (cipher.isArchived) {
if (cipher.isArchived && !cipher.isDeleted) {
menu.push({
label: this.i18nService.t("unArchive"),
click: async () => {

View File

@@ -31,81 +31,75 @@
</bit-toggle>
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
<ng-container header *ngIf="!isAdminConsoleActive">
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</tr>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</ng-container>
<tbody>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<a
bitBadge
href="{{ cipherDocs.get(r.id) }}"
target="_blank"
rel="noreferrer"
*ngIf="cipherDocs.has(r.id)"
>
{{ "instructions" | i18n }}</a
>
</td>
</tr>
</ng-template>
</tbody></bit-table
>
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
<td bitCell class="tw-text-right">
<a
bitBadge
href="{{ cipherDocs.get(row.id) }}"
target="_blank"
rel="noreferrer"
*ngIf="cipherDocs.has(row.id)"
>
{{ "instructions" | i18n }}</a
>
</td>
</ng-template>
</bit-table-scroll>
</ng-container>
</div>
</bit-container>

View File

@@ -32,68 +32,63 @@
</bit-toggle>
</ng-container>
</bit-toggle-group>
<bit-table [dataSource]="dataSource">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
<ng-container header *ngIf="!isAdminConsoleActive">
<tr bitRow>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</tr>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>
<app-vault-icon [cipher]="r"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(r)"
title="{{ 'editItemWithName' | i18n: r.name }}"
>{{ r.name }}</a
>
</ng-container>
<ng-template #cantManage>
<span>{{ r.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && r.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="r.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ r.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="r.organizationId"
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
<a
bitLink
href="#"
appStopClick
(click)="selectCipher(row)"
title="{{ 'editItemWithName' | i18n: row.name }}"
>{{ row.name }}</a
>
</app-org-badge>
</td>
</tr>
</ng-container>
<ng-template #cantManage>
<span>{{ row.name }}</span>
</ng-template>
<ng-container *ngIf="!organization && row.organizationId">
<i
class="bwi bwi-collection-shared tw-ml-1"
appStopProp
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="row.hasAttachments">
<i
class="bwi bwi-paperclip tw-ml-1"
appStopProp
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
<app-org-badge
*ngIf="!organization"
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
</td>
</ng-template>
</bit-table>
</bit-table-scroll>
</ng-container>
</div>
</bit-container>

View File

@@ -2,8 +2,10 @@
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
<div class="tw-w-full">
<a bitButton routerLink="./" *ngIf="!homepage">
{{ "backToReports" | i18n }}
</a>
@if (!homepage) {
<a bitButton routerLink="./">
{{ "backToReports" | i18n }}
</a>
}
</div>
</div>

View File

@@ -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();
}
}

View File

@@ -171,37 +171,47 @@
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
@if (!viewingOrgVault) {
<button bitMenuItem type="button" *ngIf="showFavorite" (click)="toggleFavorite()">
@if (showFavorite) {
<button bitMenuItem type="button" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>
}
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</button>
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
<button bitMenuItem *ngIf="showClone" type="button" (click)="clone()">
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
<button
bitMenuItem
*ngIf="showAssignToCollections"
type="button"
(click)="assignToCollections()"
>
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
<button bitMenuItem *ngIf="showEventLogs" type="button" (click)="events()">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
@if (!isDeleted && canEditCipher) {
<button bitMenuItem type="button" (click)="editCipher()">
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "edit" | i18n }}
</button>
}
@if (showAttachments) {
<button bitMenuItem type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
}
@if (showClone) {
<button bitMenuItem type="button" (click)="clone()">
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
}
@if (showAssignToCollections) {
<button
bitMenuItem
*ngIf="showAssignToCollections"
type="button"
(click)="assignToCollections()"
>
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
}
@if (showEventLogs) {
<button bitMenuItem type="button" (click)="events()">
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
}
@if (showArchiveButton) {
@if (userCanArchive) {
<button bitMenuItem (click)="archive()" type="button">

View File

@@ -161,7 +161,9 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return false;
}
return CipherViewLikeUtils.isArchived(this.cipher);
return (
CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher)
);
}
protected get clickAction() {
@@ -191,7 +193,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
// Do not show attachments button if:
// item is archived AND user is not premium user
protected get showAttachments() {
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
if ((CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) || this.isDeleted) {
return false;
}
return this.canEditCipher || this.hasAttachments;
@@ -387,7 +389,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
protected get showFavorite() {
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
if (
(!this.viewingOrgVault &&
CipherViewLikeUtils.isArchived(this.cipher) &&
!this.userCanArchive) ||
CipherViewLikeUtils.isDeleted(this.cipher)
) {
return false;
}
return true;

View File

@@ -84,20 +84,19 @@
{{ "assignToCollections" | i18n }}
</button>
<button *ngIf="bulkArchiveAllowed" type="button" bitMenuItem (click)="bulkArchive()">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
@if (bulkArchiveAllowed) {
<button type="button" bitMenuItem (click)="bulkArchive()">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
<button
*ngIf="bulkUnarchiveAllowed"
type="button"
bitMenuItem
(click)="bulkUnarchive()"
>
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
@if (bulkUnarchiveAllowed) {
<button type="button" bitMenuItem (click)="bulkUnarchive()">
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
}
<button
*ngIf="canRestoreSelected$ | async"

View File

@@ -277,7 +277,12 @@ export class VaultItemsComponent<C extends CipherViewLike> {
get bulkArchiveAllowed() {
const hasCollectionsSelected = this.selection.selected.some((item) => item.collection);
if (this.selection.selected.length === 0 || !this.userCanArchive || hasCollectionsSelected) {
if (
this.selection.selected.length === 0 ||
!this.userCanArchive ||
hasCollectionsSelected ||
this.showBulkTrashOptions
) {
return false;
}
@@ -291,7 +296,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
// Bulk Unarchive button should appear for Archive vault even if user does not have archive permissions
get bulkUnarchiveAllowed() {
if (this.selection.selected.length === 0) {
if (this.selection.selected.length === 0 || this.showBulkTrashOptions) {
return false;
}

View File

@@ -1271,6 +1271,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}
restore = async (c: C): Promise<boolean> => {
let toastMessage;
if (!CipherViewLikeUtils.isDeleted(c)) {
return;
}
@@ -1284,13 +1285,19 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
return;
}
if (CipherViewLikeUtils.isArchived(c)) {
toastMessage = this.i18nService.t("archivedItemRestored");
} else {
toastMessage = this.i18nService.t("restoredItem");
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.cipherService.restoreWithServer(uuidAsString(c.id), activeUserId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItem"),
message: toastMessage,
});
this.refresh();
} catch (e) {
@@ -1299,11 +1306,18 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
};
async bulkRestore(ciphers: C[]) {
let toastMessage;
if (ciphers.some((c) => !c.edit)) {
this.showMissingPermissionsError();
return;
}
if (ciphers.some((c) => !CipherViewLikeUtils.isArchived(c))) {
toastMessage = this.i18nService.t("restoredItems");
} else {
toastMessage = this.i18nService.t("archivedItemsRestored");
}
if (!(await this.repromptCipher(ciphers))) {
return;
}
@@ -1323,7 +1337,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("restoredItems"),
message: toastMessage,
});
this.refresh();
}

View File

@@ -5424,6 +5424,12 @@
"restoreSelected": {
"message": "Restore selected"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"archivedItemsRestored": {
"message": "Archived items restored"
},
"restoredItem": {
"message": "Item restored"
},

View File

@@ -23,6 +23,7 @@ export enum FeatureFlag {
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
SSHAgentV2 = "ssh-agent-v2",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
@@ -107,6 +108,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,

View File

@@ -2,7 +2,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -14,7 +13,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
@@ -42,7 +40,6 @@
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
linkType="primary"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
@@ -50,7 +47,7 @@
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
@@ -61,7 +58,6 @@
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -73,7 +69,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>

View File

@@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f
import { AriaDisableDirective } from "../a11y";
import { ariaDisableElement } from "../utils";
export type LinkType = "primary" | "secondary" | "contrast" | "light";
export const LinkTypes = [
"primary",
"secondary",
"contrast",
"light",
"default",
"subtle",
"success",
"warning",
"danger",
] as const;
export type LinkType = (typeof LinkTypes)[number];
const linkStyles: Record<LinkType, string[]> = {
primary: [
"!tw-text-primary-600",
"hover:!tw-text-primary-700",
"focus-visible:before:tw-ring-primary-600",
],
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
contrast: [
"!tw-text-contrast",
"hover:!tw-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"tw-text-fg-contrast",
"hover:tw-text-fg-contrast",
"focus-visible:before:tw-ring-fg-contrast",
],
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
};
const commonStyles = [
@@ -32,16 +45,18 @@ const commonStyles = [
"tw-rounded",
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-secondary-300",
"disabled:hover:!tw-text-secondary-300",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
@@ -63,14 +78,14 @@ const commonStyles = [
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
"aria-disabled:tw-pointer-events-none",
"aria-disabled:!tw-text-secondary-300",
"aria-disabled:hover:!tw-text-secondary-300",
"aria-disabled:!tw-text-fg-disabled",
"aria-disabled:hover:!tw-text-fg-disabled",
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("primary");
readonly linkType = input<LinkType>("default");
}
/**

View File

@@ -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

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
@@ -14,7 +14,7 @@ export default {
],
argTypes: {
linkType: {
options: ["primary", "secondary", "contrast"],
options: LinkTypes.map((type) => type),
control: { type: "radio" },
},
},
@@ -30,48 +30,153 @@ type Story = StoryObj<ButtonLinkDirective>;
export const Default: Story = {
render: (args) => ({
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
`,
}),
args: {
linkType: "primary",
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const AllVariations: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const InteractionStates: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const Buttons: Story = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">Button</button>
</div>
@@ -100,9 +205,17 @@ export const Buttons: Story = {
export const Anchors: StoryObj<AnchorLinkDirective> = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">Anchor</a>
</div>
@@ -138,18 +251,15 @@ export const Inline: Story = {
</span>
`,
}),
args: {
linkType: "primary",
},
};
export const Disabled: Story = {
export const Inactive: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
</div>
`,

View File

@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
A random password
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -112,13 +112,12 @@ class KitchenSinkDialogComponent {
<div class="tw-my-6">
<h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1>
<a bitLink linkType="primary" href="#">This is a link</a>
<a bitLink href="#">This is a link</a>
<p bitTypography="body1" class="tw-inline">
&nbsp;and this is a link button popover trigger:&nbsp;
</p>
<button
bitLink
linkType="primary"
[bitPopoverTriggerFor]="myPopover"
#triggerRef="popoverTrigger"
type="button"

View File

@@ -120,5 +120,19 @@ describe("ArchiveCipherUtilitiesService", () => {
message: "errorOccurred",
});
});
it("calls password reprompt check when unarchiving", async () => {
await service.unarchiveCipher(mockCipher);
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(mockCipher);
});
it("returns early when password reprompt fails on unarchive", async () => {
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
await service.unarchiveCipher(mockCipher);
expect(cipherArchiveService.unarchiveWithServer).not.toHaveBeenCalled();
});
});
});

View File

@@ -74,7 +74,14 @@ export class ArchiveCipherUtilitiesService {
* @param cipher The cipher to unarchive
* @returns The unarchived cipher on success, or undefined on failure
*/
async unarchiveCipher(cipher: CipherView) {
async unarchiveCipher(cipher: CipherView, skipReprompt = false) {
if (!skipReprompt) {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
if (!repromptPassed) {
return;
}
}
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
try {
const cipherResponse = await this.cipherArchiveService.unarchiveWithServer(