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:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100);
|
||||
}
|
||||
});
|
||||
|
||||
this.autofillOverlayContentService.refreshMenuLayerPosition();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -137,6 +137,9 @@ export class PhishingDetectionService {
|
||||
|
||||
this._didInit = true;
|
||||
return () => {
|
||||
// Dispose phishing data service resources
|
||||
phishingDataService.dispose();
|
||||
|
||||
initSub.unsubscribe();
|
||||
this._didInit = false;
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2092,6 +2092,9 @@
|
||||
"permanentlyDeletedItem": {
|
||||
"message": "Item permanently deleted"
|
||||
},
|
||||
"archivedItemRestored": {
|
||||
"message": "Archived item restored"
|
||||
},
|
||||
"restoredItem": {
|
||||
"message": "Item restored"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -5424,6 +5424,12 @@
|
||||
"restoreSelected": {
|
||||
"message": "Restore selected"
|
||||
},
|
||||
"archivedItemRestored": {
|
||||
"message": "Archived item restored"
|
||||
},
|
||||
"archivedItemsRestored": {
|
||||
"message": "Archived items restored"
|
||||
},
|
||||
"restoredItem": {
|
||||
"message": "Item restored"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
|
||||
@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
A random password
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -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">
|
||||
and this is a link button popover trigger:
|
||||
</p>
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user