1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-26 14:23:46 +00:00

Merge branch 'main' into anders/electron-builder

This commit is contained in:
Anders Åberg
2026-01-22 18:20:19 +01:00
committed by GitHub
103 changed files with 3706 additions and 751 deletions

View File

@@ -111,7 +111,7 @@ jobs:
working-directory: ./apps/desktop/desktop_native
run: cargo build
- name: Test Ubuntu
- name: Linux unit tests
if: ${{ matrix.os=='ubuntu-22.04' }}
working-directory: ./apps/desktop/desktop_native
run: |
@@ -120,17 +120,21 @@ jobs:
mkdir -p ~/.local/share/keyrings
eval "$(printf '\n' | gnome-keyring-daemon --unlock)"
eval "$(printf '\n' | /usr/bin/gnome-keyring-daemon --start)"
cargo test -- --test-threads=1
cargo test --lib -- --test-threads=1
- name: Test macOS
- name: MacOS unit tests
if: ${{ matrix.os=='macos-14' }}
working-directory: ./apps/desktop/desktop_native
run: cargo test -- --test-threads=1
run: cargo test --lib -- --test-threads=1
- name: Test Windows
- name: Windows unit tests
if: ${{ matrix.os=='windows-2022'}}
working-directory: ./apps/desktop/desktop_native
run: cargo test --workspace --exclude=desktop_napi -- --test-threads=1
run: cargo test --lib --workspace --exclude=desktop_napi -- --test-threads=1
- name: Doc tests
working-directory: ./apps/desktop/desktop_native
run: cargo test --doc
rust-coverage:
name: Rust Coverage

View File

@@ -2473,6 +2473,9 @@
"permanentlyDeletedItem": {
"message": "Item permanently deleted"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"restoreItem": {
"message": "Restore item"
},
@@ -5670,6 +5673,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

@@ -25,7 +25,6 @@
<div
class="tw-size-full tw-styled-scrollbar"
data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)"
[ngClass]="{
'!tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(),

View File

@@ -3,13 +3,17 @@ import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
inject,
input,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { filter, switchMap, fromEvent, startWith, map } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ScrollLayoutHostDirective } from "@bitwarden/components";
import { ScrollLayoutHostDirective, ScrollLayoutService } from "@bitwarden/components";
@Component({
selector: "popup-page",
@@ -22,6 +26,8 @@ import { ScrollLayoutHostDirective } from "@bitwarden/components";
})
export class PopupPageComponent {
protected i18nService = inject(I18nService);
private scrollLayout = inject(ScrollLayoutService);
private destroyRef = inject(DestroyRef);
readonly loading = input<boolean>(false);
@@ -33,10 +39,21 @@ export class PopupPageComponent {
protected readonly scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
constructor() {
this.scrollLayout.scrollableRef$
.pipe(
filter((ref): ref is ElementRef<HTMLElement> => ref != null),
switchMap((ref) =>
fromEvent(ref.nativeElement, "scroll").pipe(
startWith(null),
map(() => ref.nativeElement.scrollTop !== 0),
),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((isScrolled) => this.scrolled.set(isScrolled));
}
/** Accessible loading label for the spinner. Defaults to "loading" */
readonly loadingText = input<string | undefined>(this.i18nService.t("loading"));
handleScroll(event: Event) {
this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);
}
}

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

@@ -27,8 +27,12 @@ import {
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AUTOFILL_NUDGE_SERVICE } from "@bitwarden/angular/vault";
import { SingleNudgeService } from "@bitwarden/angular/vault/services/default-single-nudge.service";
import {
AUTOFILL_NUDGE_SERVICE,
AUTO_CONFIRM_NUDGE_SERVICE,
AutoConfirmNudgeService,
} from "@bitwarden/angular/vault";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import {
LoginComponentService,
TwoFactorAuthComponentService,
@@ -786,9 +790,14 @@ const safeProviders: SafeProvider[] = [
],
}),
safeProvider({
provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken<SingleNudgeService>,
provide: AUTOFILL_NUDGE_SERVICE as SafeInjectionToken<BrowserAutofillNudgeService>,
useClass: BrowserAutofillNudgeService,
deps: [],
deps: [StateProvider, VaultProfileService, LogService],
}),
safeProvider({
provide: AUTO_CONFIRM_NUDGE_SERVICE as SafeInjectionToken<AutoConfirmNudgeService>,
useClass: AutoConfirmNudgeService,
deps: [StateProvider, AutomaticUserConfirmationService],
}),
];

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

@@ -1,4 +1,3 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core";
import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
@@ -394,21 +393,28 @@ describe("VaultV2Component", () => {
expect(values[values.length - 1]).toBe(false);
});
it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => {
it("passes popup-page scroll region element to scroll position service", fakeAsync(() => {
const fixture = TestBed.createComponent(VaultV2Component);
const component = fixture.componentInstance;
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
(component as any).virtualScrollElement = {} as CdkVirtualScrollableElement;
component.ngAfterViewInit();
expect(scrollSvc.start).not.toHaveBeenCalled();
allFilters$.next({ any: true });
fixture.detectChanges();
tick();
expect(scrollSvc.start).toHaveBeenCalledTimes(1);
expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement);
const scrollRegion = fixture.nativeElement.querySelector(
'[data-testid="popup-layout-scroll-region"]',
) as HTMLElement;
flush();
// Unblock loading
itemsLoading$.next(false);
readySubject$.next(true);
allFilters$.next({});
tick();
expect(scrollSvc.start).toHaveBeenCalledWith(scrollRegion);
}));
it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => {

View File

@@ -1,7 +1,7 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import {
@@ -47,6 +47,7 @@ import {
ButtonModule,
DialogService,
NoItemsModule,
ScrollLayoutService,
ToastService,
TypographyModule,
} from "@bitwarden/components";
@@ -119,11 +120,7 @@ type VaultState = UnionOfValues<typeof VaultState>;
],
providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
export class VaultV2Component implements OnInit, OnDestroy {
NudgeType = NudgeType;
cipherType = CipherType;
private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
@@ -308,16 +305,21 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
});
}
ngAfterViewInit(): void {
if (this.virtualScrollElement) {
// The filters component can cause the size of the virtual scroll element to change,
// which can cause the scroll position to be land in the wrong spot. To fix this,
// wait until all filters are populated before restoring the scroll position.
this.allFilters$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.vaultScrollPositionService.start(this.virtualScrollElement!);
private readonly scrollLayout = inject(ScrollLayoutService);
private readonly _scrollPositionEffect = effect((onCleanup) => {
const sub = combineLatest([this.scrollLayout.scrollableRef$, this.allFilters$, this.loading$])
.pipe(
filter(([ref, _filters, loading]) => !!ref && !loading),
take(1),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([ref]) => {
this.vaultScrollPositionService.start(ref!.nativeElement);
});
}
}
onCleanup(() => sub.unsubscribe());
});
async ngOnInit() {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -1,4 +1,3 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
import { NavigationEnd, Router } from "@angular/router";
import { Subject, Subscription } from "rxjs";
@@ -66,21 +65,18 @@ describe("VaultPopupScrollPositionService", () => {
});
describe("start", () => {
const elementScrolled$ = new Subject();
const focus = jest.fn();
const nativeElement = {
scrollTop: 0,
querySelector: jest.fn(() => ({ focus })),
addEventListener: jest.fn(),
style: {
visibility: "",
},
};
const virtualElement = {
elementScrolled: () => elementScrolled$,
getElementRef: () => ({ nativeElement }),
scrollTo: jest.fn(),
} as unknown as CdkVirtualScrollableElement;
let scrollElement: HTMLElement;
beforeEach(() => {
scrollElement = document.createElement("div");
(scrollElement as any).scrollTo = jest.fn(function scrollTo(opts: { top?: number }) {
if (opts?.top != null) {
(scrollElement as any).scrollTop = opts.top;
}
});
(scrollElement as any).scrollTop = 0;
});
afterEach(() => {
// remove the actual subscription created by `.subscribe`
@@ -89,47 +85,55 @@ describe("VaultPopupScrollPositionService", () => {
describe("initial scroll position", () => {
beforeEach(() => {
(virtualElement.scrollTo as jest.Mock).mockClear();
nativeElement.querySelector.mockClear();
((scrollElement as any).scrollTo as jest.Mock).mockClear();
});
it("does not scroll when `scrollPosition` is null", () => {
service["scrollPosition"] = null;
service.start(virtualElement);
service.start(scrollElement);
expect(virtualElement.scrollTo).not.toHaveBeenCalled();
expect((scrollElement as any).scrollTo).not.toHaveBeenCalled();
});
it("scrolls the virtual element to `scrollPosition`", fakeAsync(() => {
it("scrolls the element to `scrollPosition` (async via setTimeout)", fakeAsync(() => {
service["scrollPosition"] = 500;
nativeElement.scrollTop = 500;
service.start(virtualElement);
service.start(scrollElement);
tick();
expect(virtualElement.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 500 });
expect((scrollElement as any).scrollTo).toHaveBeenCalledWith({
behavior: "instant",
top: 500,
});
expect((scrollElement as any).scrollTop).toBe(500);
}));
});
describe("scroll listener", () => {
it("unsubscribes from any existing subscription", () => {
service.start(virtualElement);
service.start(scrollElement);
expect(unsubscribe).toHaveBeenCalled();
});
it("subscribes to `elementScrolled`", fakeAsync(() => {
virtualElement.measureScrollOffset = jest.fn(() => 455);
it("stores scrollTop on subsequent scroll events (skips first)", fakeAsync(() => {
service["scrollPosition"] = null;
service.start(virtualElement);
service.start(scrollElement);
elementScrolled$.next(null); // first subscription is skipped by `skip(1)`
elementScrolled$.next(null);
// First scroll event is intentionally ignored (equivalent to old skip(1)).
(scrollElement as any).scrollTop = 111;
scrollElement.dispatchEvent(new Event("scroll"));
tick();
expect(service["scrollPosition"]).toBeNull();
// Second scroll event should persist.
(scrollElement as any).scrollTop = 455;
scrollElement.dispatchEvent(new Event("scroll"));
tick();
expect(virtualElement.measureScrollOffset).toHaveBeenCalledTimes(1);
expect(virtualElement.measureScrollOffset).toHaveBeenCalledWith("top");
expect(service["scrollPosition"]).toBe(455);
}));
});

View File

@@ -1,8 +1,7 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter, skip, Subscription } from "rxjs";
import { filter, fromEvent, Subscription } from "rxjs";
@Injectable({
providedIn: "root",
@@ -31,24 +30,25 @@ export class VaultPopupScrollPositionService {
}
/** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */
start(virtualScrollElement: CdkVirtualScrollableElement) {
start(scrollElement: HTMLElement) {
if (this.hasScrollPosition()) {
// Use `setTimeout` to scroll after rendering is complete
setTimeout(() => {
virtualScrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
scrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
});
}
this.scrollSubscription?.unsubscribe();
// Skip the first scroll event to avoid settings the scroll from the above `scrollTo` call
this.scrollSubscription = virtualScrollElement
?.elementScrolled()
.pipe(skip(1))
.subscribe(() => {
const offset = virtualScrollElement.measureScrollOffset("top");
this.scrollPosition = offset;
});
let skipped = false;
this.scrollSubscription = fromEvent(scrollElement, "scroll").subscribe(() => {
if (!skipped) {
skipped = true;
return;
}
this.scrollPosition = scrollElement.scrollTop;
});
}
/** Stops the scroll listener from updating the stored location. */

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

@@ -19,5 +19,14 @@ windows-core = { workspace = true }
[dependencies]
anyhow = { workspace = true }
[target.'cfg(windows)'.dev-dependencies]
windows = { workspace = true, features = [
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_Foundation",
"Win32_System_LibraryLoader",
"Win32_Graphics_Gdi",
] }
[lints]
workspace = true

View File

@@ -0,0 +1,324 @@
#![cfg(target_os = "windows")]
use std::{
sync::{Arc, Mutex},
thread,
time::Duration,
};
use autotype::{get_foreground_window_title, type_input};
use serial_test::serial;
use tracing::debug;
use windows::Win32::{
Foundation::{COLORREF, HINSTANCE, HMODULE, HWND, LPARAM, LRESULT, WPARAM},
Graphics::Gdi::{CreateSolidBrush, UpdateWindow, ValidateRect, COLOR_WINDOW},
System::LibraryLoader::{GetModuleHandleA, GetModuleHandleW},
UI::WindowsAndMessaging::*,
};
use windows_core::{s, w, Result, PCSTR, PCWSTR};
struct TestWindow {
handle: HWND,
capture: Option<InputCapture>,
}
impl Drop for TestWindow {
fn drop(&mut self) {
// Clean up the InputCapture pointer
unsafe {
let capture_ptr = GetWindowLongPtrW(self.handle, GWLP_USERDATA) as *mut InputCapture;
if !capture_ptr.is_null() {
let _ = Box::from_raw(capture_ptr);
}
CloseWindow(self.handle).expect("window handle should be closeable");
DestroyWindow(self.handle).expect("window handle should be destroyable");
}
}
}
// state to capture keyboard input
#[derive(Clone)]
struct InputCapture {
chars: Arc<Mutex<Vec<char>>>,
}
impl InputCapture {
fn new() -> Self {
Self {
chars: Arc::new(Mutex::new(Vec::new())),
}
}
fn get_chars(&self) -> Vec<char> {
self.chars
.lock()
.expect("mutex should not be poisoned")
.clone()
}
}
// Custom window procedure that captures input
unsafe extern "system" fn capture_input_proc(
handle: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
WM_CREATE => {
// Store the InputCapture pointer in window data
let create_struct = lparam.0 as *const CREATESTRUCTW;
let capture_ptr = (*create_struct).lpCreateParams as *mut InputCapture;
SetWindowLongPtrW(handle, GWLP_USERDATA, capture_ptr as isize);
LRESULT(0)
}
WM_CHAR => {
// Get the InputCapture from window data
let capture_ptr = GetWindowLongPtrW(handle, GWLP_USERDATA) as *mut InputCapture;
if !capture_ptr.is_null() {
let capture = &*capture_ptr;
if let Some(ch) = char::from_u32(wparam.0 as u32) {
capture
.chars
.lock()
.expect("mutex should not be poisoned")
.push(ch);
}
}
LRESULT(0)
}
WM_DESTROY => {
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcW(handle, msg, wparam, lparam),
}
}
// A pointer to the window procedure
type ProcType = unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT;
// <https://learn.microsoft.com/en-us/windows/win32/api/winuser/nc-winuser-wndproc>
extern "system" fn show_window_proc(
handle: HWND, // the window handle
message: u32, // the system message
wparam: WPARAM, /* additional message information. The contents of the wParam parameter
* depend on the value of the message parameter. */
lparam: LPARAM, /* additional message information. The contents of the lParam parameter
* depend on the value of the message parameter. */
) -> LRESULT {
unsafe {
match message {
WM_PAINT => {
debug!("WM_PAINT");
let res = ValidateRect(Some(handle), None);
debug_assert!(res.ok().is_ok());
LRESULT(0)
}
WM_DESTROY => {
debug!("WM_DESTROY");
PostQuitMessage(0);
LRESULT(0)
}
_ => DefWindowProcA(handle, message, wparam, lparam),
}
}
}
impl TestWindow {
fn set_foreground(&self) -> Result<()> {
unsafe {
let _ = ShowWindow(self.handle, SW_SHOW);
let _ = SetForegroundWindow(self.handle);
let _ = UpdateWindow(self.handle);
let _ = SetForegroundWindow(self.handle);
}
std::thread::sleep(std::time::Duration::from_millis(100));
Ok(())
}
fn wait_for_input(&self, timeout_ms: u64) {
let start = std::time::Instant::now();
while start.elapsed().as_millis() < timeout_ms as u128 {
process_messages();
thread::sleep(Duration::from_millis(10));
}
}
}
fn process_messages() {
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
fn create_input_window(title: PCWSTR, proc_type: ProcType) -> Result<TestWindow> {
unsafe {
let instance = GetModuleHandleW(None).unwrap_or(HMODULE(std::ptr::null_mut()));
let instance: HINSTANCE = instance.into();
debug_assert!(!instance.is_invalid());
let window_class = w!("show_window");
// Register window class with our custom proc
let wc = WNDCLASSW {
lpfnWndProc: Some(proc_type),
hInstance: instance,
lpszClassName: window_class,
hbrBackground: CreateSolidBrush(COLORREF(
(COLOR_WINDOW.0 + 1).try_into().expect("i32 to fit in u32"),
)),
..Default::default()
};
let _atom = RegisterClassW(&wc);
let capture = InputCapture::new();
// Pass InputCapture as lpParam
let capture_ptr = Box::into_raw(Box::new(capture.clone()));
// Create window
// <https://learn.microsoft.com/en-us/windows/win32/learnwin32/creating-a-window>
let handle = CreateWindowExW(
WINDOW_EX_STYLE(0),
window_class,
title,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
400,
300,
None,
None,
Some(instance),
Some(capture_ptr as *const _),
)
.expect("window should be created");
// Process pending messages
process_messages();
thread::sleep(Duration::from_millis(100));
Ok(TestWindow {
handle,
capture: Some(capture),
})
}
}
fn create_title_window(title: PCSTR, proc_type: ProcType) -> Result<TestWindow> {
unsafe {
let instance = GetModuleHandleA(None)?;
let instance: HINSTANCE = instance.into();
debug_assert!(!instance.is_invalid());
let window_class = s!("input_window");
// Register window class with our custom proc
// <https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-wndclassa>
let wc = WNDCLASSA {
hCursor: LoadCursorW(None, IDC_ARROW)?,
hInstance: instance,
lpszClassName: window_class,
style: CS_HREDRAW | CS_VREDRAW,
lpfnWndProc: Some(proc_type),
..Default::default()
};
let _atom = RegisterClassA(&wc);
// Create window
// <https://learn.microsoft.com/en-us/windows/win32/learnwin32/creating-a-window>
let handle = CreateWindowExA(
WINDOW_EX_STYLE::default(),
window_class,
title,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
800,
600,
None,
None,
Some(instance),
None,
)
.expect("window should be created");
Ok(TestWindow {
handle,
capture: None,
})
}
}
#[serial]
#[test]
fn test_get_active_window_title_success() {
let title;
{
let window = create_title_window(s!("TITLE_FOOBAR"), show_window_proc).unwrap();
window.set_foreground().unwrap();
title = get_foreground_window_title().unwrap();
}
assert_eq!(title, "TITLE_FOOBAR\0".to_owned());
thread::sleep(Duration::from_millis(100));
}
#[serial]
#[test]
fn test_get_active_window_title_doesnt_fail_if_empty_title() {
let title;
{
let window = create_title_window(s!(""), show_window_proc).unwrap();
window.set_foreground().unwrap();
title = get_foreground_window_title();
}
assert_eq!(title.unwrap(), "".to_owned());
thread::sleep(Duration::from_millis(100));
}
#[serial]
#[test]
fn test_type_input_success() {
const TAB: u16 = 0x09;
let chars;
{
let window = create_input_window(w!("foo"), capture_input_proc).unwrap();
window.set_foreground().unwrap();
type_input(
&[
0x66, 0x6F, 0x6C, 0x6C, 0x6F, 0x77, 0x5F, 0x74, 0x68, 0x65, TAB, 0x77, 0x68, 0x69,
0x74, 0x65, 0x5F, 0x72, 0x61, 0x62, 0x62, 0x69, 0x74,
],
&["Control".to_owned(), "Alt".to_owned(), "B".to_owned()],
)
.unwrap();
// Wait for and process input messages
window.wait_for_input(250);
// Verify captured input
let capture = window.capture.as_ref().unwrap();
chars = capture.get_chars();
}
assert!(!chars.is_empty(), "No input captured");
let input_str = String::from_iter(chars.iter());
let input_str = input_str.replace("\t", "_");
assert_eq!(input_str, "follow_the_white_rabbit");
thread::sleep(Duration::from_millis(100));
}

View File

@@ -86,7 +86,8 @@
"signIgnore": [
"MacOS/desktop_proxy",
"MacOS/desktop_proxy.inherit",
"Contents/Plugins/autofill-extension.appex"
"Contents/Plugins/autofill-extension.appex",
"Frameworks/Electron Framework.framework/(Electron Framework|Libraries|Resources|Versions/Current)/.*"
],
"target": ["dmg", "zip"]
},

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
</dict>
</plist>

View File

@@ -256,7 +256,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements";
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
@@ -409,7 +409,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension.entitlements";
CODE_SIGN_ENTITLEMENTS = "autofill-extension/autofill_extension_enabled.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1DF713B2B342F6900F29026"
BuildableName = "autofill-extension.appex"
BlueprintName = "autofill-extension"
ReferencedContainer = "container:desktop.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
</LaunchAction>
<ProfileAction
buildConfiguration = "ReleaseAppStore"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E1DF713B2B342F6900F29026"
BuildableName = "autofill-extension.appex"
BlueprintName = "autofill-extension"
ReferencedContainer = "container:desktop.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "ReleaseAppStore"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -46,7 +46,7 @@
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",
"pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never -c.mac.identity=null",
"pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never -c.mac.identity=null -c.mas.identity=$CSC_NAME -c.mas.provisioningProfile=bitwarden_desktop_developer_id.provisionprofile -c.mas.entitlements=resources/entitlements.mas.autofill-enabled.plist",
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never",

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
<key>com.apple.developer.team-identifier</key>
<string>LTZ2PFU5D6</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
<array>
<string>/Library/Application Support/Mozilla/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome Dev/NativeMessagingHosts/</string>
<string>/Library/Application Support/Google/Chrome Canary/NativeMessagingHosts/</string>
<string>/Library/Application Support/Chromium/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Beta/NativeMessagingHosts/</string>
<string>/Library/Application Support/Microsoft Edge Dev/NativeMessagingHosts/</string>
<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>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
</dict>
</plist>

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

@@ -16,7 +16,9 @@ async function run(context) {
const appPath = `${context.appOutDir}/${appName}.app`;
const macBuild = context.electronPlatformName === "darwin";
const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName);
const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds
const isMasDevBuild =
context.electronPlatformName === "mas" && context.targets.at(0)?.name === "mas-dev";
const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName) || isMasDevBuild;
let shouldResign = false;
@@ -31,7 +33,6 @@ async function run(context) {
fse.mkdirSync(path.join(appPath, "Contents/PlugIns"));
}
fse.copySync(extensionPath, path.join(appPath, "Contents/PlugIns/autofill-extension.appex"));
shouldResign = true;
}
}

View File

@@ -1,4 +1,4 @@
<bit-layout class="!tw-h-full">
<bit-layout class="!tw-h-full" rounded>
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n" />

View File

@@ -89,6 +89,7 @@ import {
PlatformUtilsService,
PlatformUtilsService as PlatformUtilsServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -432,6 +433,7 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
MessagingServiceAbstraction,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

View File

@@ -1,8 +1,10 @@
import { MockProxy, mock } from "jest-mock-extended";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -19,12 +21,14 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -45,6 +49,7 @@ describe("DesktopSetInitialPasswordService", () => {
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let messagingService: MockProxy<MessagingService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let registerSdkService: MockProxy<RegisterSdkService>;
beforeEach(() => {
apiService = mock<ApiService>();
@@ -59,6 +64,7 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
messagingService = mock<MessagingService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
registerSdkService = mock<RegisterSdkService>();
sut = new DesktopSetInitialPasswordService(
apiService,
@@ -73,6 +79,7 @@ describe("DesktopSetInitialPasswordService", () => {
userDecryptionOptionsService,
messagingService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -179,4 +186,36 @@ describe("DesktopSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
it("should send a 'redrawMenu' message", async () => {
// Arrange
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "newPasswordHint",
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId" as OrganizationId,
resetPasswordAutoEnroll: false,
newPassword: "newPassword123!",
salt: "user@example.com" as MasterPasswordSalt,
};
const userId = "userId" as UserId;
const superSpy = jest
.spyOn(
DefaultSetInitialPasswordService.prototype,
"initializePasswordJitPasswordUserV2Encryption",
)
.mockResolvedValue(undefined);
// Act
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
// Assert
expect(superSpy).toHaveBeenCalledWith(credentials, userId);
expect(messagingService.send).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
superSpy.mockRestore();
});
});
});

View File

@@ -1,6 +1,7 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -14,6 +15,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -34,6 +36,7 @@ export class DesktopSetInitialPasswordService
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
private messagingService: MessagingService,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -47,6 +50,7 @@ export class DesktopSetInitialPasswordService
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
}
@@ -59,4 +63,13 @@ export class DesktopSetInitialPasswordService
this.messagingService.send("redrawMenu");
}
override async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
this.messagingService.send("redrawMenu");
}
}

View File

@@ -1,25 +1,93 @@
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
</app-header>
<!-- Send List Component -->
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
@if (useDrawerEditMode()) {
<div class="tw-m-4 tw-p-4">
<!-- New dialog-based layout (feature flag enabled) -->
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
</app-header>
<!-- Send List Component -->
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
</div>
} @else {
<!-- Old split-panel layout (feature flag disabled) -->
<div id="sends" class="vault">
<div class="send-items-panel tw-w-2/5">
<!-- Header with Send title and New button -->
<app-header class="tw-block tw-pt-6 tw-px-6">
@if (!disableSend()) {
<button type="button" bitButton buttonType="primary" (click)="addSendWithoutType()">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
}
</app-header>
<div class="tw-mb-4 tw-px-6">
<!-- Send List Component -->
<tools-send-list
[sends]="filteredSends()"
[loading]="loading()"
[disableSend]="disableSend()"
[listState]="listState()"
[searchText]="currentSearchText()"
(editSend)="onEditSend($event)"
(copySend)="onCopySend($event)"
(deleteSend)="onDeleteSend($event)"
(removePassword)="onRemovePassword($event)"
>
<button
slot="empty-button"
type="button"
bitButton
buttonType="primary"
(click)="addSendWithoutType()"
>
{{ "newSend" | i18n }}
</button>
</tools-send-list>
</div>
</div>
<!-- Edit/Add panel (right side) -->
@if (action() == "add" || action() == "edit") {
<app-send-add-edit
id="addEdit"
class="details"
[sendId]="sendId()"
[type]="selectedSendType()"
(onSavedSend)="savedSend($event)"
(onCancelled)="closeEditPanel()"
(onDeletedSend)="closeEditPanel()"
></app-send-add-edit>
}
<!-- Bitwarden logo (shown when no send is selected) -->
@if (!action()) {
<div class="logo tw-w-1/2">
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
}
</div>
}

View File

@@ -49,6 +49,7 @@ describe("SendV2Component", () => {
let sendApiService: MockProxy<SendApiService>;
let toastService: MockProxy<ToastService>;
let i18nService: MockProxy<I18nService>;
let configService: MockProxy<ConfigService>;
beforeEach(async () => {
sendService = mock<SendService>();
@@ -62,6 +63,10 @@ describe("SendV2Component", () => {
sendApiService = mock<SendApiService>();
toastService = mock<ToastService>();
i18nService = mock<I18nService>();
configService = mock<ConfigService>();
// Setup configService mock - feature flag returns true to test the new drawer mode
configService.getFeatureFlag$.mockReturnValue(of(true));
// Setup environmentService mock
environmentService.getEnvironment.mockResolvedValue({
@@ -117,7 +122,7 @@ describe("SendV2Component", () => {
useValue: mock<BillingAccountProfileStateService>(),
},
{ provide: MessagingService, useValue: mock<MessagingService>() },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: ConfigService, useValue: configService },
{
provide: ActivatedRoute,
useValue: {

View File

@@ -1,11 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
inject,
signal,
viewChild,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, switchMap, lastValueFrom } from "rxjs";
@@ -15,6 +17,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -36,12 +40,27 @@ import {
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { DesktopHeaderComponent } from "../../layout/header";
import { AddEditComponent } from "../send/add-edit.component";
const Action = Object.freeze({
/** No action is currently active. */
None: "",
/** The user is adding a new Send. */
Add: "add",
/** The user is editing an existing Send. */
Edit: "edit",
} as const);
type Action = (typeof Action)[keyof typeof Action];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-send-v2",
imports: [
JslibModule,
ButtonModule,
AddEditComponent,
SendListComponent,
NewSendDropdownV2Component,
DesktopHeaderComponent,
@@ -54,13 +73,19 @@ import { DesktopHeaderComponent } from "../../layout/header";
},
],
templateUrl: "./send-v2.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendV2Component {
protected readonly addEditComponent = viewChild(AddEditComponent);
protected readonly sendId = signal<string | null>(null);
protected readonly action = signal<Action>(Action.None);
private readonly selectedSendTypeOverride = signal<SendType | undefined>(undefined);
private sendFormConfigService = inject(DefaultSendFormConfigService);
private sendItemsService = inject(SendItemsService);
private policyService = inject(PolicyService);
private accountService = inject(AccountService);
private configService = inject(ConfigService);
private i18nService = inject(I18nService);
private platformUtilsService = inject(PlatformUtilsService);
private environmentService = inject(EnvironmentService);
@@ -70,6 +95,11 @@ export class SendV2Component {
private logService = inject(LogService);
private cdr = inject(ChangeDetectorRef);
protected readonly useDrawerEditMode = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone2),
{ initialValue: false },
);
protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, {
initialValue: [],
});
@@ -119,28 +149,79 @@ export class SendV2Component {
});
}
protected readonly selectedSendType = computed(() => {
const action = this.action();
const typeOverride = this.selectedSendTypeOverride();
if (action === Action.Add && typeOverride !== undefined) {
return typeOverride;
}
const sendId = this.sendId();
return this.filteredSends().find((s) => s.id === sendId)?.type;
});
protected async addSend(type: SendType): Promise<void> {
const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
if (this.useDrawerEditMode()) {
const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
await lastValueFrom(dialogRef.closed);
} else {
this.action.set(Action.Add);
this.sendId.set(null);
this.selectedSendTypeOverride.set(type);
const component = this.addEditComponent();
if (component) {
await component.resetAndLoad();
}
}
}
protected async selectSend(sendId: SendId): Promise<void> {
const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId);
/** Used by old UI to add a send without specifying type (defaults to Text) */
protected async addSendWithoutType(): Promise<void> {
await this.addSend(SendType.Text);
}
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
protected closeEditPanel(): void {
this.action.set(Action.None);
this.sendId.set(null);
this.selectedSendTypeOverride.set(undefined);
}
await lastValueFrom(dialogRef.closed);
protected async savedSend(send: SendView): Promise<void> {
await this.selectSend(send.id);
}
protected async selectSend(sendId: string): Promise<void> {
if (this.useDrawerEditMode()) {
const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId as SendId);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
} else {
if (sendId === this.sendId() && this.action() === Action.Edit) {
return;
}
this.action.set(Action.Edit);
this.sendId.set(sendId);
const component = this.addEditComponent();
if (component) {
component.sendId = sendId;
await component.refresh();
}
}
}
protected async onEditSend(send: SendView): Promise<void> {
await this.selectSend(send.id as SendId);
await this.selectSend(send.id);
}
protected async onCopySend(send: SendView): Promise<void> {
@@ -176,6 +257,11 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("removedPassword"),
});
if (!this.useDrawerEditMode() && this.sendId() === send.id) {
this.sendId.set(null);
await this.selectSend(send.id);
}
} catch (e) {
this.logService.error(e);
}
@@ -199,5 +285,9 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("deletedSend"),
});
if (!this.useDrawerEditMode()) {
this.closeEditPanel();
}
}
}

View File

@@ -10,6 +10,7 @@ import {
mergeMap,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -52,6 +53,8 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service"
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
private registrationRequest: autofill.PasskeyRegistrationRequest;
private featureFlag?: FeatureFlag;
private isEnabled: boolean = false;
constructor(
private logService: LogService,
@@ -60,19 +63,26 @@ export class DesktopAutofillService implements OnDestroy {
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
private authService: AuthService,
private platformUtilsService: PlatformUtilsService,
) {}
platformUtilsService: PlatformUtilsService,
) {
const deviceType = platformUtilsService.getDevice();
if (deviceType === DeviceType.MacOsDesktop) {
this.featureFlag = FeatureFlag.MacOsNativeCredentialSync;
}
}
async init() {
// Currently only supported for MacOS
if (this.platformUtilsService.getDevice() !== DeviceType.MacOsDesktop) {
this.isEnabled =
this.featureFlag && (await this.configService.getFeatureFlag(this.featureFlag));
if (!this.isEnabled) {
return;
}
this.configService
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.getFeatureFlag$(this.featureFlag)
.pipe(
distinctUntilChanged(),
tap((enabled) => (this.isEnabled = enabled)),
filter((enabled) => enabled === true), // Only proceed if feature is enabled
switchMap(() => {
return combineLatest([
@@ -199,11 +209,11 @@ export class DesktopAutofillService implements OnDestroy {
listenIpc() {
ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled",
`listenPasskeyRegistration: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -230,11 +240,11 @@ export class DesktopAutofillService implements OnDestroy {
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled",
`listenPasskeyAssertionWithoutUserInterface: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -297,11 +307,11 @@ export class DesktopAutofillService implements OnDestroy {
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled",
`listenPasskeyAssertion: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null);
callback(new Error("Native credential sync feature flag is disabled"), null);
return;
}
@@ -324,9 +334,9 @@ export class DesktopAutofillService implements OnDestroy {
// Listen for native status messages
ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => {
if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) {
if (!this.isEnabled) {
this.logService.debug(
"listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled",
`listenNativeStatus: Native credential sync feature flag (${this.featureFlag}) is disabled`,
);
return;
}

View File

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

View File

@@ -0,0 +1,29 @@
/**
* Desktop UI Migration
*
* These are temporary styles during the desktop ui migration.
**/
/**
* This removes any padding applied by the bit-layout to content.
* This should be revisited once the table is migrated, and again once drawers are migrated.
**/
bit-layout {
#main-content {
padding: 0 0 0 0;
}
}
/**
* Send list panel styling for send-v2 component
* Temporary during migration - width handled by tw-w-2/5
**/
.vault > .send-items-panel {
order: 2;
min-width: 200px;
border-right: 1px solid;
@include themify($themes) {
background-color: themed("backgroundColor");
border-right-color: themed("borderColor");
}
}

View File

@@ -15,5 +15,6 @@
@import "left-nav.scss";
@import "loading.scss";
@import "plugins.scss";
@import "migration.scss";
@import "../../../../libs/angular/src/scss/icons.scss";
@import "../../../../libs/components/src/multi-select/scss/bw.theme";

View File

@@ -36,15 +36,11 @@
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
*ngIf="cipher.id && !cipher?.organizationId && !cipher.isDeleted && action === 'view'"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
@if (showCloneOption) {
<button type="button" class="primary" (click)="clone()" appA11yTitle="{{ 'clone' | i18n }}">
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
}
</ng-container>
<div class="right" *ngIf="hasFooterAction">
<button

View File

@@ -75,6 +75,7 @@ export class ItemFooterComponent implements OnInit, OnChanges {
protected showArchiveButton = false;
protected showUnarchiveButton = false;
protected userCanArchive = false;
constructor(
protected cipherService: CipherService,
@@ -134,6 +135,16 @@ export class ItemFooterComponent implements OnInit, OnChanges {
);
}
protected get showCloneOption() {
return (
this.cipher.id &&
!this.cipher?.organizationId &&
!this.cipher.isDeleted &&
this.action === "view" &&
(!this.cipher.isArchived || this.userCanArchive)
);
}
cancel() {
this.onCancel.emit(this.cipher);
}
@@ -173,16 +184,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) {
@@ -234,11 +252,16 @@ export class ItemFooterComponent implements OnInit, OnChanges {
),
);
this.userCanArchive = userCanArchive;
this.showArchiveButton =
cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived;
// 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

@@ -10,69 +10,79 @@
[organizationId]="organizationId"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher()"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
[submitButtonText]="submitButtonText()"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view
*ngIf="action === 'view'"
[cipher]="cipher()"
[collections]="collections"
>
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
@if (!!action) {
<div class="details">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher()"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
(onArchiveToggle)="refreshCurrentCipher()"
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
[submitButtonText]="submitButtonText()"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
@if (action === "view") {
<app-cipher-view [cipher]="cipher()" [collections]="collections"> </app-cipher-view>
}
@if (action === "add" || action === "edit" || action === "clone") {
<vault-cipher-form
#vaultForm
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
(formStatusChange$)="formStatusChanged($event)"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
<bit-item slot="attachment-button">
<button
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
[disabled]="formDisabled"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
</div>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
@if (action !== "add" && action !== "edit" && action !== "view" && action !== "clone") {
<div id="logo" class="logo">
<div class="content">
<div class="inner-content">
@if (activeFilter.status === "archive" && !(hasArchivedCiphers$ | async)) {
<bit-no-items [icon]="itemTypesIcon">
<div slot="title">
{{ "noItemsInArchive" | i18n }}
</div>
<p slot="description" bitTypography="body2" class="tw-max-w-md tw-text-center">
{{ "noItemsInArchiveDesc" | i18n }}
</p>
</bit-no-items>
} @else {
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
}
</div>
</div>
</div>
</div>
}
<div class="left-nav">
<app-vault-filter
class="vault-filters"

View File

@@ -27,6 +27,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { ItemTypes } from "@bitwarden/assets/svg";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -69,6 +70,7 @@ import {
ToastService,
CopyClickListener,
COPY_CLICK_LISTENER,
NoItemsModule,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
@@ -124,6 +126,7 @@ const BroadcasterSubscriptionId = "VaultComponent";
NavComponent,
VaultFilterModule,
VaultItemsV2Component,
NoItemsModule,
],
providers: [
{
@@ -203,6 +206,7 @@ export class VaultV2Component<C extends CipherViewLike>
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
readonly userHasPremium = signal<boolean>(false);
protected itemTypesIcon = ItemTypes;
/** Tracks the disabled status of the edit cipher form */
protected formDisabled: boolean = false;
@@ -221,6 +225,12 @@ export class VaultV2Component<C extends CipherViewLike>
: this.i18nService.t("save");
});
protected hasArchivedCiphers$ = this.userId$.pipe(
switchMap((userId) =>
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)),
),
);
private componentIsDestroyed$ = new Subject<boolean>();
private allOrganizations: Organization[] = [];
private allCollections: CollectionView[] = [];
@@ -611,7 +621,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

@@ -2,6 +2,6 @@ export { PoliciesComponent } from "./policies.component";
export { ossPolicyEditRegister } from "./policy-edit-register";
export { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
export { POLICY_EDIT_REGISTER } from "./policy-register-token";
export { AutoConfirmPolicyDialogComponent } from "./auto-confirm-edit-policy-dialog.component";
export { AutoConfirmPolicy } from "./policy-edit-definitions";
export { PolicyEditDialogResult } from "./policy-edit-dialog.component";
export * from "./policy-edit-dialogs";

View File

@@ -15,8 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyDialogComponent } from "../auto-confirm-edit-policy-dialog.component";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
import { AutoConfirmPolicyDialogComponent } from "../policy-edit-dialogs/auto-confirm-edit-policy-dialog.component";
export class AutoConfirmPolicy extends BasePolicyEditDefinition {
name = "autoConfirm";

View File

@@ -1,7 +1,10 @@
export { DisableSendPolicy } from "./disable-send.component";
export { DesktopAutotypeDefaultSettingPolicy } from "./autotype-policy.component";
export { MasterPasswordPolicy } from "./master-password.component";
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
export {
OrganizationDataOwnershipPolicy,
OrganizationDataOwnershipPolicyComponent,
} from "./organization-data-ownership.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { RemoveUnlockWithPinPolicy } from "./remove-unlock-with-pin.component";
export { RequireSsoPolicy } from "./require-sso.component";

View File

@@ -1,8 +1,57 @@
<bit-callout type="warning">
{{ "personalOwnershipExemption" | i18n }}
</bit-callout>
<p>
{{ "organizationDataOwnershipDescContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
</ng-template>

View File

@@ -1,22 +1,38 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { of, Observable } from "rxjs";
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
export interface VNextPolicyRequest {
policy: PolicyRequest;
metadata: {
defaultUserCollectionName: string;
};
}
export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
name = "organizationDataOwnership";
description = "personalOwnershipPolicyDesc";
description = "organizationDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = OrganizationDataOwnershipPolicyComponent;
showDescription = false;
display$(organization: Organization, configService: ConfigService): Observable<boolean> {
// TODO Remove this entire component upon verifying that it can be deleted due to its sole reliance of the CreateDefaultLocation feature flag
return of(false);
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems)
.pipe(map((enabled) => !enabled));
}
}
@@ -26,4 +42,61 @@ export class OrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
imports: [SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrganizationDataOwnershipPolicyComponent extends BasePolicyEditComponent {}
export class OrganizationDataOwnershipPolicyComponent
extends BasePolicyEditComponent
implements OnInit
{
constructor(
private dialogService: DialogService,
private i18nService: I18nService,
private encryptService: EncryptService,
) {
super();
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent, {
positionStrategy: new CenterPositionStrategy(),
});
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
if (!this.policy) {
throw new Error("Policy was not found");
}
const defaultUserCollectionName = await this.getEncryptedDefaultUserCollectionName(orgKey);
const request: VNextPolicyRequest = {
policy: {
enabled: this.enabled.value ?? false,
data: this.buildRequestData(),
},
metadata: {
defaultUserCollectionName,
},
};
return request;
}
private async getEncryptedDefaultUserCollectionName(orgKey: OrgKey): Promise<EncString> {
const defaultCollectionName = this.i18nService.t("myItems");
const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey);
if (!encrypted.encryptedString) {
throw new Error("Encryption error");
}
return encrypted.encryptedString;
}
}

View File

@@ -1,57 +1,52 @@
<p>
{{ "organizationDataOwnershipDescContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<ng-container [ngTemplateOutlet]="steps[step()]()"></ng-container>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #step0>
<p>
{{ "centralizeDataOwnershipDesc" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "centralizeDataOwnershipContentAnchor" | i18n }}
<i class="bwi bwi-external-link"></i>
</a>
</p>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
<div class="tw-text-left tw-overflow-hidden tw-mb-2">
<strong>{{ "benefits" | i18n }}:</strong>
<ul class="tw-pl-7 tw-space-y-2 tw-pt-2">
<li>
{{ "centralizeDataOwnershipBenefit1" | i18n }}
</li>
<li>
{{ "centralizeDataOwnershipBenefit2" | i18n }}
</li>
<li>
{{ "centralizeDataOwnershipBenefit3" | i18n }}
</li>
</ul>
</div>
<bit-form-control>
<input class="tw-mt-4" type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
</ng-template>
<ng-template #step1>
<div class="tw-flex tw-flex-col tw-gap-2 tw-overflow-hidden">
<span>
{{ "centralizeDataOwnershipWarningDesc" | i18n }}
</span>
<a
class="tw-mt-4"
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "centralizeDataOwnershipWarningLink" | i18n }}
<i class="bwi bwi-external-link"></i>
</a>
</div>
</ng-template>

View File

@@ -1,18 +1,30 @@
import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom } from "rxjs";
import {
ChangeDetectionStrategy,
Component,
OnInit,
signal,
Signal,
TemplateRef,
viewChild,
WritableSignal,
} from "@angular/core";
import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrgKey } from "@bitwarden/common/types/key";
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
import { EncString } from "@bitwarden/sdk-internal";
import { SharedModule } from "../../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component";
import { OrganizationDataOwnershipPolicyDialogComponent } from "../policy-edit-dialogs";
interface VNextPolicyRequest {
export interface VNextPolicyRequest {
policy: PolicyRequest;
metadata: {
defaultUserCollectionName: string;
@@ -20,11 +32,17 @@ interface VNextPolicyRequest {
}
export class vNextOrganizationDataOwnershipPolicy extends BasePolicyEditDefinition {
name = "organizationDataOwnership";
description = "organizationDataOwnershipDesc";
name = "centralizeDataOwnership";
description = "centralizeDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
editDialogComponent = OrganizationDataOwnershipPolicyDialogComponent;
override display$(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.MigrateMyVaultToMyItems);
}
}
@Component({
@@ -38,27 +56,16 @@ export class vNextOrganizationDataOwnershipPolicyComponent
implements OnInit
{
constructor(
private dialogService: DialogService,
private i18nService: I18nService,
private encryptService: EncryptService,
) {
super();
}
private readonly policyForm: Signal<TemplateRef<any> | undefined> = viewChild("step0");
private readonly warningContent: Signal<TemplateRef<any> | undefined> = viewChild("step1");
protected readonly step: WritableSignal<number> = signal(0);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent, {
positionStrategy: new CenterPositionStrategy(),
});
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
protected steps = [this.policyForm, this.warningContent];
async buildVNextRequest(orgKey: OrgKey): Promise<VNextPolicyRequest> {
if (!this.policy) {
@@ -90,4 +97,8 @@ export class vNextOrganizationDataOwnershipPolicyComponent
return encrypted.encryptedString;
}
setStep(step: number) {
this.step.set(step);
}
}

View File

@@ -16,6 +16,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import {
DIALOG_DATA,
DialogConfig,
@@ -28,7 +29,7 @@ import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { BasePolicyEditDefinition, BasePolicyEditComponent } from "./base-policy-edit.component";
import { vNextOrganizationDataOwnershipPolicyComponent } from "./policy-edit-definitions/vnext-organization-data-ownership.component";
import { VNextPolicyRequest } from "./policy-edit-definitions/organization-data-ownership.component";
export type PolicyEditDialogData = {
/**
@@ -73,13 +74,24 @@ export class PolicyEditDialogComponent implements AfterViewInit {
private formBuilder: FormBuilder,
protected dialogRef: DialogRef<PolicyEditDialogResult>,
protected toastService: ToastService,
private keyService: KeyService,
protected keyService: KeyService,
) {}
get policy(): BasePolicyEditDefinition {
return this.data.policy;
}
/**
* Type guard to check if the policy component has the buildVNextRequest method.
*/
private hasVNextRequest(
component: BasePolicyEditComponent,
): component is BasePolicyEditComponent & {
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
} {
return "buildVNextRequest" in component && typeof component.buildVNextRequest === "function";
}
/**
* Instantiates the child policy component and inserts it into the view.
*/
@@ -129,7 +141,7 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
try {
if (this.policyComponent instanceof vNextOrganizationDataOwnershipPolicyComponent) {
if (this.hasVNextRequest(this.policyComponent)) {
await this.handleVNextSubmission(this.policyComponent);
} else {
await this.handleStandardSubmission();
@@ -158,7 +170,9 @@ export class PolicyEditDialogComponent implements AfterViewInit {
}
private async handleVNextSubmission(
policyComponent: vNextOrganizationDataOwnershipPolicyComponent,
policyComponent: BasePolicyEditComponent & {
buildVNextRequest: (orgKey: OrgKey) => Promise<VNextPolicyRequest>;
},
): Promise<void> {
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
@@ -173,12 +187,12 @@ export class PolicyEditDialogComponent implements AfterViewInit {
throw new Error("No encryption key for this organization.");
}
const vNextRequest = await policyComponent.buildVNextRequest(orgKey);
const request = await policyComponent.buildVNextRequest(orgKey);
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
this.data.policy.type,
vNextRequest,
request,
);
}
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {

View File

@@ -41,20 +41,15 @@ import {
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
import { AutoConfirmPolicyEditComponent } from "./policy-edit-definitions/auto-confirm-policy.component";
import { SharedModule } from "../../../../shared";
import { AutoConfirmPolicyEditComponent } from "../policy-edit-definitions/auto-confirm-policy.component";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "./policy-edit-dialog.component";
} from "../policy-edit-dialog.component";
export type MultiStepSubmit = {
sideEffect: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};
import { MultiStepSubmit } from "./models";
export type AutoConfirmPolicyDialogData = PolicyEditDialogData & {
firstTimeDialog?: boolean;
@@ -202,6 +197,7 @@ export class AutoConfirmPolicyDialogComponent
}
const autoConfirmRequest = await this.policyComponent.buildRequest();
await this.policyApiService.putPolicy(
this.data.organizationId,
this.data.policy.type,
@@ -235,7 +231,7 @@ export class AutoConfirmPolicyDialogComponent
data: null,
};
await this.policyApiService.putPolicy(
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
PolicyType.SingleOrg,
singleOrgRequest,
@@ -260,7 +256,10 @@ export class AutoConfirmPolicyDialogComponent
try {
const multiStepSubmit = await firstValueFrom(this.multiStepSubmit);
await multiStepSubmit[this.currentStep()].sideEffect();
const sideEffect = multiStepSubmit[this.currentStep()].sideEffect;
if (sideEffect) {
await sideEffect();
}
if (this.currentStep() === multiStepSubmit.length - 1) {
this.dialogRef.close("saved");

View File

@@ -0,0 +1,3 @@
export * from "./auto-confirm-edit-policy-dialog.component";
export * from "./organization-data-ownership-edit-policy-dialog.component";
export * from "./models";

View File

@@ -0,0 +1,7 @@
import { Signal, TemplateRef } from "@angular/core";
export type MultiStepSubmit = {
sideEffect?: () => Promise<void>;
footerContent: Signal<TemplateRef<unknown> | undefined>;
titleContent: Signal<TemplateRef<unknown> | undefined>;
};

View File

@@ -0,0 +1,72 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog [loading]="loading">
<ng-container bitDialogTitle>
@let title = multiStepSubmit()[currentStep()]?.titleContent();
@if (title) {
<ng-container [ngTemplateOutlet]="title"></ng-container>
}
</ng-container>
<ng-container bitDialogContent>
@if (loading) {
<div>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
}
<div [hidden]="loading">
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
</div>
<ng-template #policyForm></ng-template>
</ng-container>
<ng-container bitDialogFooter>
@let footer = multiStepSubmit()[currentStep()]?.footerContent();
@if (footer) {
<ng-container [ngTemplateOutlet]="footer"></ng-container>
}
</ng-container>
</bit-dialog>
</form>
<ng-template #step0Title>
{{ policy.name | i18n }}
</ng-template>
<ng-template #step1Title>
{{ "centralizeDataOwnershipWarningTitle" | i18n }}
</ng-template>
<ng-template #step0>
<button
bitButton
buttonType="primary"
[disabled]="saveDisabled$ | async"
bitFormButton
type="submit"
>
@if (policyComponent?.policyResponse?.enabled) {
{{ "save" | i18n }}
} @else {
{{ "continue" | i18n }}
}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>
<ng-template #step1>
<button bitButton buttonType="primary" bitFormButton type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" bitDialogClose type="button">
{{ "cancel" | i18n }}
</button>
</ng-template>

View File

@@ -0,0 +1,224 @@
import {
AfterViewInit,
ChangeDetectorRef,
Component,
Inject,
signal,
TemplateRef,
viewChild,
WritableSignal,
} from "@angular/core";
import { FormBuilder } from "@angular/forms";
import {
catchError,
combineLatest,
defer,
firstValueFrom,
from,
map,
Observable,
of,
startWith,
switchMap,
} from "rxjs";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../../shared";
import { vNextOrganizationDataOwnershipPolicyComponent } from "../policy-edit-definitions";
import {
PolicyEditDialogComponent,
PolicyEditDialogData,
PolicyEditDialogResult,
} from "../policy-edit-dialog.component";
import { MultiStepSubmit } from "./models";
/**
* Custom policy dialog component for Centralize Organization Data
* Ownership policy. Satisfies the PolicyDialogComponent interface
* structurally via its static open() function.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "organization-data-ownership-edit-policy-dialog.component.html",
imports: [SharedModule],
})
export class OrganizationDataOwnershipPolicyDialogComponent
extends PolicyEditDialogComponent
implements AfterViewInit
{
policyType = PolicyType;
protected centralizeDataOwnershipEnabled$: Observable<boolean> = defer(() =>
from(
this.policyApiService.getPolicy(
this.data.organizationId,
PolicyType.OrganizationDataOwnership,
),
).pipe(
map((policy) => policy.enabled),
catchError(() => of(false)),
),
);
protected readonly currentStep: WritableSignal<number> = signal(0);
protected readonly multiStepSubmit: WritableSignal<MultiStepSubmit[]> = signal([]);
private readonly policyForm = viewChild.required<TemplateRef<unknown>>("step0");
private readonly warningContent = viewChild.required<TemplateRef<unknown>>("step1");
private readonly policyFormTitle = viewChild.required<TemplateRef<unknown>>("step0Title");
private readonly warningTitle = viewChild.required<TemplateRef<unknown>>("step1Title");
override policyComponent: vNextOrganizationDataOwnershipPolicyComponent | undefined;
constructor(
@Inject(DIALOG_DATA) protected data: PolicyEditDialogData,
accountService: AccountService,
policyApiService: PolicyApiServiceAbstraction,
i18nService: I18nService,
cdr: ChangeDetectorRef,
formBuilder: FormBuilder,
dialogRef: DialogRef<PolicyEditDialogResult>,
toastService: ToastService,
protected keyService: KeyService,
) {
super(
data,
accountService,
policyApiService,
i18nService,
cdr,
formBuilder,
dialogRef,
toastService,
keyService,
);
}
async ngAfterViewInit() {
await super.ngAfterViewInit();
if (this.policyComponent) {
this.saveDisabled$ = combineLatest([
this.centralizeDataOwnershipEnabled$,
this.policyComponent.enabled.valueChanges.pipe(
startWith(this.policyComponent.enabled.value),
),
]).pipe(map(([policyEnabled, value]) => !policyEnabled && !value));
}
this.multiStepSubmit.set(this.buildMultiStepSubmit());
}
private buildMultiStepSubmit(): MultiStepSubmit[] {
if (this.policyComponent?.policyResponse?.enabled) {
return [
{
sideEffect: () => this.handleSubmit(),
footerContent: this.policyForm,
titleContent: this.policyFormTitle,
},
];
}
return [
{
footerContent: this.policyForm,
titleContent: this.policyFormTitle,
},
{
sideEffect: () => this.handleSubmit(),
footerContent: this.warningContent,
titleContent: this.warningTitle,
},
];
}
private async handleSubmit() {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
const orgKey = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.keyService.orgKeys$(userId)),
),
);
assertNonNullish(orgKey, "Org key not provided");
const request = await this.policyComponent.buildVNextRequest(
orgKey[this.data.organizationId as OrganizationId],
);
await this.policyApiService.putPolicyVNext(
this.data.organizationId,
this.data.policy.type,
request,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("editedPolicyId", this.i18nService.t(this.data.policy.name)),
});
if (!this.policyComponent.enabled.value) {
this.dialogRef.close("saved");
}
}
submit = async () => {
if (!this.policyComponent) {
throw new Error("PolicyComponent not initialized.");
}
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
try {
const sideEffect = this.multiStepSubmit()[this.currentStep()].sideEffect;
if (sideEffect) {
await sideEffect();
}
if (this.currentStep() === this.multiStepSubmit().length - 1) {
this.dialogRef.close("saved");
return;
}
this.currentStep.update((value) => value + 1);
this.policyComponent.setStep(this.currentStep());
} catch (error: any) {
this.toastService.showToast({
variant: "error",
message: error.message,
});
}
};
static open = (dialogService: DialogService, config: DialogConfig<PolicyEditDialogData>) => {
return dialogService.open<PolicyEditDialogResult>(
OrganizationDataOwnershipPolicyDialogComponent,
config,
);
};
}

View File

@@ -3,6 +3,7 @@ import { BehaviorSubject, of } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -20,11 +21,13 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
@@ -47,6 +50,7 @@ describe("WebSetInitialPasswordService", () => {
let organizationInviteService: MockProxy<OrganizationInviteService>;
let routerService: MockProxy<RouterService>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
let registerSdkService: MockProxy<RegisterSdkService>;
beforeEach(() => {
apiService = mock<ApiService>();
@@ -62,6 +66,7 @@ describe("WebSetInitialPasswordService", () => {
organizationInviteService = mock<OrganizationInviteService>();
routerService = mock<RouterService>();
accountCryptographicStateService = mock<AccountCryptographicStateService>();
registerSdkService = mock<RegisterSdkService>();
sut = new WebSetInitialPasswordService(
apiService,
@@ -77,6 +82,7 @@ describe("WebSetInitialPasswordService", () => {
organizationInviteService,
routerService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -208,4 +214,36 @@ describe("WebSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption(...)", () => {
it("should call routerService.getAndClearLoginRedirectUrl() and organizationInviteService.clearOrganizationInvitation()", async () => {
// Arrange
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "newPasswordHint",
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId" as OrganizationId,
resetPasswordAutoEnroll: false,
newPassword: "newPassword123!",
salt: "user@example.com" as MasterPasswordSalt,
};
const userId = "userId" as UserId;
const superSpy = jest
.spyOn(
Object.getPrototypeOf(Object.getPrototypeOf(sut)),
"initializePasswordJitPasswordUserV2Encryption",
)
.mockResolvedValue(undefined);
// Act
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
// Assert
expect(superSpy).toHaveBeenCalledWith(credentials, userId);
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
superSpy.mockRestore();
});
});
});

View File

@@ -1,6 +1,7 @@
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { DefaultSetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordUserType,
@@ -14,6 +15,7 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { UserId } from "@bitwarden/common/types/guid";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { RouterService } from "@bitwarden/web-vault/app/core";
@@ -36,6 +38,7 @@ export class WebSetInitialPasswordService
private organizationInviteService: OrganizationInviteService,
private routerService: RouterService,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {
super(
apiService,
@@ -49,6 +52,7 @@ export class WebSetInitialPasswordService
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
}
@@ -83,4 +87,15 @@ export class WebSetInitialPasswordService
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
}
override async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
await super.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
// TODO: Investigate refactoring the following logic in https://bitwarden.atlassian.net/browse/PM-22615
await this.routerService.getAndClearLoginRedirectUrl();
await this.organizationInviteService.clearOrganizationInvitation();
}
}

View File

@@ -6,11 +6,11 @@ import { Router } from "@angular/router";
import {
CollectionAdminService,
DefaultCollectionAdminService,
OrganizationUserApiService,
CollectionService,
OrganizationUserService,
DefaultCollectionAdminService,
DefaultOrganizationUserService,
OrganizationUserApiService,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
@@ -27,17 +27,17 @@ import {
OBSERVABLE_DISK_LOCAL_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
SafeInjectionToken,
SECURE_STORAGE,
SYSTEM_LANGUAGE,
SafeInjectionToken,
WINDOW,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import {
RegistrationFinishService as RegistrationFinishServiceAbstraction,
LoginComponentService,
SsoComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SsoComponentService,
TwoFactorAuthDuoComponentService,
} from "@bitwarden/auth/angular";
import {
@@ -90,6 +90,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
@@ -120,9 +121,9 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
BiometricsService,
KdfConfigService,
KeyService as KeyServiceAbstraction,
BiometricsService,
} from "@bitwarden/key-management";
import {
LockComponentService,
@@ -135,17 +136,17 @@ import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/va
import { flagEnabled } from "../../utils/flags";
import {
POLICY_EDIT_REGISTER,
ossPolicyEditRegister,
POLICY_EDIT_REGISTER,
} from "../admin-console/organizations/policies";
import {
LinkSsoService,
WebChangePasswordService,
WebRegistrationFinishService,
WebLoginComponentService,
WebLoginDecryptionOptionsService,
WebTwoFactorAuthDuoComponentService,
LinkSsoService,
WebRegistrationFinishService,
WebSetInitialPasswordService,
WebTwoFactorAuthDuoComponentService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebPremiumInterestStateService } from "../billing/services/premium-interest/web-premium-interest-state.service";
@@ -320,6 +321,7 @@ const safeProviders: SafeProvider[] = [
OrganizationInviteService,
RouterService,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

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

@@ -5418,6 +5418,12 @@
"restoreSelected": {
"message": "Restore selected"
},
"archivedItemRestored": {
"message": "Archived item restored"
},
"archivedItemsRestored": {
"message": "Archived items restored"
},
"restoredItem": {
"message": "Item restored"
},
@@ -5909,6 +5915,37 @@
}
}
},
"centralizeDataOwnership":{
"message": "Centralize organization ownership"
},
"centralizeDataOwnershipDesc":{
"message": "All member items will be owned and managed by the organization. Admins and owners are exempt. "
},
"centralizeDataOwnershipContentAnchor": {
"message": "Learn more about centralized ownership",
"description": "This will be used as a hyperlink"
},
"benefits":{
"message": "Benefits"
},
"centralizeDataOwnershipBenefit1":{
"message": "Gain full visibility into credential health, including shared and unshared items."
},
"centralizeDataOwnershipBenefit2":{
"message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps."
},
"centralizeDataOwnershipBenefit3":{
"message": "Give all users a dedicated \"My Items\" space for managing their own logins."
},
"centralizeDataOwnershipWarningTitle": {
"message": "Prompt members to transfer their items"
},
"centralizeDataOwnershipWarningDesc": {
"message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime."
},
"centralizeDataOwnershipWarningLink": {
"message": "Learn more about the transfer"
},
"organizationDataOwnership": {
"message": "Enforce organization data ownership"
},

View File

@@ -1,4 +1,4 @@
import { firstValueFrom } from "rxjs";
import { concatMap, firstValueFrom } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -19,19 +19,32 @@ import { AccountCryptographicStateService } from "@bitwarden/common/key-manageme
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService, KdfConfig } from "@bitwarden/key-management";
import {
fromSdkKdfConfig,
KdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal";
import {
SetInitialPasswordService,
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordUserType,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -47,6 +60,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
protected organizationUserApiService: OrganizationUserApiService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
protected accountCryptographicStateService: AccountCryptographicStateService,
protected registerSdkService: RegisterSdkService,
) {}
async setInitialPassword(
@@ -199,6 +213,126 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
}
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
async initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void> {
if (userId == null) {
throw new Error("User ID is required.");
}
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} is required.`);
}
}
const { newPasswordHint, orgSsoIdentifier, orgId, resetPasswordAutoEnroll, newPassword, salt } =
credentials;
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error("Organization keys response is null.");
}
const registerResult = await firstValueFrom(
this.registerSdkService.registerClient$(userId).pipe(
concatMap(async (sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return await ref.value
.auth()
.registration()
.post_keys_for_jit_password_registration({
org_id: asUuid<SdkOrganizationId>(orgId),
org_public_key: organizationKeys.publicKey,
master_password: newPassword,
master_password_hint: newPasswordHint,
salt: salt,
organization_sso_identifier: orgSsoIdentifier,
user_id: asUuid<SdkUserId>(userId),
reset_password_enroll: resetPasswordAutoEnroll,
});
}),
),
);
if (!("V2" in registerResult.account_cryptographic_state)) {
throw new Error("Unexpected V2 account cryptographic state");
}
// Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration
// Set account cryptography state
await this.accountCryptographicStateService.setAccountCryptographicState(
registerResult.account_cryptographic_state,
userId,
);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
const masterPasswordUnlockData = MasterPasswordUnlockData.fromSdk(
registerResult.master_password_unlock,
);
await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(registerResult.user_key) as UserKey,
userId,
);
await this.updateLegacyState(
newPassword,
fromSdkKdfConfig(registerResult.master_password_unlock.kdf),
new EncString(registerResult.master_password_unlock.masterKeyWrappedUserKey),
userId,
masterPasswordUnlockData,
);
}
private async makeMasterKeyEncryptedUserKey(
masterKey: MasterKey,
userId: UserId,
@@ -244,6 +378,37 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);
}
// Deprecated legacy support - to be removed in future
private async updateLegacyState(
newPassword: string,
kdfConfig: KdfConfig,
masterKeyWrappedUserKey: EncString,
userId: UserId,
masterPasswordUnlockData: MasterPasswordUnlockData,
) {
// TODO Remove HasMasterPassword from UserDecryptionOptions https://bitwarden.atlassian.net/browse/PM-23475
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
userDecryptionOpts,
);
// TODO Remove KDF state https://bitwarden.atlassian.net/browse/PM-30661
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
// TODO Remove master key memory state https://bitwarden.atlassian.net/browse/PM-23477
await this.masterPasswordService.setMasterKeyEncryptedUserKey(masterKeyWrappedUserKey, userId);
// TODO Removed with https://bitwarden.atlassian.net/browse/PM-30676
await this.masterPasswordService.setLegacyMasterKeyFromUnlockData(
newPassword,
masterPasswordUnlockData,
userId,
);
}
/**
* As part of [PM-28494], adding this setting path to accommodate the changes that are
* emerging with pm-23246-unlock-with-master-password-unlock-data.
@@ -310,44 +475,4 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
async setInitialPasswordTdeOffboarding(
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) {
const { newMasterKey, newServerMasterKeyHash, newPasswordHint } = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
if (userId == null) {
throw new Error("userId not found. Could not set password.");
}
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not set password.");
}
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
userKey,
);
if (!newMasterKeyEncryptedUserKey[1].encryptedString) {
throw new Error("newMasterKeyEncryptedUserKey not found. Could not set password.");
}
const request = new UpdateTdeOffboardingPasswordRequest();
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
request.newMasterPasswordHash = newServerMasterKeyHash;
request.masterPasswordHint = newPasswordHint;
await this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
}
}

View File

@@ -1,5 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
// Polyfill for Symbol.dispose required by the service's use of `using` keyword
import "core-js/proposals/explicit-resource-management";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, Observable, of } from "rxjs";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
@@ -27,17 +30,35 @@ import {
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
import { Rc } from "@bitwarden/common/platform/misc/reference-counting/rc";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import {
DEFAULT_KDF_CONFIG,
fromSdkKdfConfig,
KdfConfigService,
KeyService,
} from "@bitwarden/key-management";
import {
AuthClient,
BitwardenClient,
WrappedAccountCryptographicState,
} from "@bitwarden/sdk-internal";
import { DefaultSetInitialPasswordService } from "./default-set-initial-password.service.implementation";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -58,6 +79,7 @@ describe("DefaultSetInitialPasswordService", () => {
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
const registerSdkService = mock<RegisterSdkService>();
let userId: UserId;
let userKey: UserKey;
@@ -94,6 +116,7 @@ describe("DefaultSetInitialPasswordService", () => {
organizationUserApiService,
userDecryptionOptionsService,
accountCryptographicStateService,
registerSdkService,
);
});
@@ -834,4 +857,246 @@ describe("DefaultSetInitialPasswordService", () => {
});
});
});
describe("initializePasswordJitPasswordUserV2Encryption()", () => {
let mockSdkRef: {
value: MockProxy<BitwardenClient>;
[Symbol.dispose]: jest.Mock;
};
let mockSdk: {
take: jest.Mock;
};
let mockRegistration: jest.Mock;
const userId = "d4e2e3a1-1b5e-4c3b-8d7a-9f8e7d6c5b4a" as UserId;
const orgId = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" as OrganizationId;
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: "test-hint",
orgSsoIdentifier: "org-sso-id",
orgId: orgId,
resetPasswordAutoEnroll: false,
newPassword: "Test@Password123!",
salt: "user@example.com" as unknown as MasterPasswordSalt,
};
const orgKeys: OrganizationKeysResponse = {
publicKey: "org-public-key-base64",
privateKey: "org-private-key-encrypted",
} as OrganizationKeysResponse;
const sdkRegistrationResult = {
account_cryptographic_state: {
V2: {
private_key: makeEncString().encryptedString!,
signed_public_key: "test-signed-public-key",
signing_key: makeEncString().encryptedString!,
security_state: "test-security-state",
},
},
master_password_unlock: {
kdf: {
pBKDF2: {
iterations: 600000,
},
},
masterKeyWrappedUserKey: makeEncString().encryptedString!,
salt: "user@example.com" as unknown as MasterPasswordSalt,
},
user_key: makeSymmetricCryptoKey(64).keyB64,
};
beforeEach(() => {
jest.clearAllMocks();
mockSdkRef = {
value: mock<BitwardenClient>(),
[Symbol.dispose]: jest.fn(),
};
mockSdkRef.value.auth.mockReturnValue({
registration: jest.fn().mockReturnValue({
post_keys_for_jit_password_registration: jest.fn(),
}),
} as unknown as AuthClient);
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$.mockReturnValue(
of(mockSdk) as unknown as Observable<Rc<BitwardenClient>>,
);
organizationApiService.getKeys.mockResolvedValue(orgKeys);
mockRegistration = mockSdkRef.value.auth().registration()
.post_keys_for_jit_password_registration as unknown as jest.Mock;
mockRegistration.mockResolvedValue(sdkRegistrationResult);
const mockUserDecryptionOpts = new UserDecryptionOptions({ hasMasterPassword: false });
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(mockUserDecryptionOpts),
);
});
it("should successfully initialize JIT password user", async () => {
await sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(userId);
expect(mockRegistration).toHaveBeenCalledWith(
expect.objectContaining({
org_id: credentials.orgId,
org_public_key: orgKeys.publicKey,
master_password: credentials.newPassword,
master_password_hint: credentials.newPasswordHint,
salt: credentials.salt,
organization_sso_identifier: credentials.orgSsoIdentifier,
user_id: userId,
reset_password_enroll: credentials.resetPasswordAutoEnroll,
}),
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
sdkRegistrationResult.account_cryptographic_state,
userId,
);
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
userId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
SymmetricCryptoKey.fromString(sdkRegistrationResult.user_key) as UserKey,
userId,
);
// Verify legacy state updates below
expect(userDecryptionOptionsService.userDecryptionOptionsById$).toHaveBeenCalledWith(userId);
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
expect.objectContaining({ hasMasterPassword: true }),
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(
userId,
fromSdkKdfConfig(sdkRegistrationResult.master_password_unlock.kdf),
);
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
new EncString(sdkRegistrationResult.master_password_unlock.masterKeyWrappedUserKey),
userId,
);
expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
MasterPasswordUnlockData.fromSdk(sdkRegistrationResult.master_password_unlock),
userId,
);
});
describe("input validation", () => {
it.each([
"newPasswordHint",
"orgSsoIdentifier",
"orgId",
"resetPasswordAutoEnroll",
"newPassword",
"salt",
])("should throw error when %s is null", async (field) => {
const invalidCredentials = {
...credentials,
[field]: null,
} as unknown as InitializeJitPasswordCredentials;
const promise = sut.initializePasswordJitPasswordUserV2Encryption(
invalidCredentials,
userId,
);
await expect(promise).rejects.toThrow(`${field} is required.`);
expect(organizationApiService.getKeys).not.toHaveBeenCalled();
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
it("should throw error when userId is null", async () => {
const nullUserId = null as unknown as UserId;
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, nullUserId);
await expect(promise).rejects.toThrow("User ID is required.");
expect(organizationApiService.getKeys).not.toHaveBeenCalled();
});
});
describe("organization API error handling", () => {
it("should throw when organizationApiService.getKeys returns null", async () => {
organizationApiService.getKeys.mockResolvedValue(
null as unknown as OrganizationKeysResponse,
);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("Organization keys response is null.");
expect(organizationApiService.getKeys).toHaveBeenCalledWith(credentials.orgId);
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
it("should throw when organizationApiService.getKeys rejects", async () => {
const apiError = new Error("API network error");
organizationApiService.getKeys.mockRejectedValue(apiError);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("API network error");
expect(registerSdkService.registerClient$).not.toHaveBeenCalled();
});
});
describe("SDK error handling", () => {
it("should throw when SDK is not available", async () => {
organizationApiService.getKeys.mockResolvedValue(orgKeys);
registerSdkService.registerClient$.mockReturnValue(
of(null) as unknown as Observable<Rc<BitwardenClient>>,
);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("SDK not available");
});
it("should throw when SDK registration fails", async () => {
const sdkError = new Error("SDK crypto operation failed");
organizationApiService.getKeys.mockResolvedValue(orgKeys);
mockRegistration.mockRejectedValue(sdkError);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("SDK crypto operation failed");
});
});
it("should throw when account_cryptographic_state is not V2", async () => {
const invalidResult = {
...sdkRegistrationResult,
account_cryptographic_state: { V1: {} } as unknown as WrappedAccountCryptographicState,
};
mockRegistration.mockResolvedValue(invalidResult);
const promise = sut.initializePasswordJitPasswordUserV2Encryption(credentials, userId);
await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state");
});
});
});

View File

@@ -21,14 +21,16 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { assertTruthy, assertNonNullish } from "@bitwarden/common/auth/utils";
import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import {
AnonLayoutWrapperDataService,
ButtonModule,
@@ -39,6 +41,7 @@ import {
import { I18nPipe } from "@bitwarden/ui-common";
import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
@@ -86,6 +89,7 @@ export class SetInitialPasswordComponent implements OnInit {
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -101,6 +105,51 @@ export class SetInitialPasswordComponent implements OnInit {
this.initializing = false;
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: {
const accountEncryptionV2 = await this.configService.getFeatureFlag(
FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration,
);
if (accountEncryptionV2) {
await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult);
return;
}
await this.setInitialPassword(passwordInputResult);
break;
}
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
private async establishUserType() {
if (!this.userId) {
throw new Error("userId not found. Could not determine user type.");
@@ -189,22 +238,39 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
private async setInitialPasswordJitMPUserV2Encryption(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password for SSO JIT master password encryption user.";
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertTruthy(passwordInputResult.salt, "salt", ctx);
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertTruthy(this.userId, "userId", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
switch (this.userType) {
case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER:
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
break;
default:
this.logService.error(
`Unexpected user type: ${this.userType}. Could not set initial password.`,
);
this.validationService.showError("Unexpected user type. Could not set initial password.");
try {
const credentials: InitializeJitPasswordCredentials = {
newPasswordHint: passwordInputResult.newPasswordHint,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId as OrganizationId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
newPassword: passwordInputResult.newPassword,
salt: passwordInputResult.salt,
};
await this.setInitialPasswordService.initializePasswordJitPasswordUserV2Encryption(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
@@ -307,17 +373,4 @@ export class SetInitialPasswordComponent implements OnInit {
});
}
}
protected async logout() {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" },
content: { key: "logOutConfirmation" },
acceptButtonText: { key: "logOut" },
type: "warning",
});
if (confirmed) {
this.messagingService.send("logout");
}
}
}

View File

@@ -1,5 +1,5 @@
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
@@ -61,6 +61,24 @@ export interface SetInitialPasswordTdeOffboardingCredentials {
newPasswordHint: string;
}
/**
* Credentials required to initialize a just-in-time (JIT) provisioned user with a master password.
*/
export interface InitializeJitPasswordCredentials {
/** Hint for the new master password */
newPasswordHint: string;
/** SSO identifier for the organization */
orgSsoIdentifier: string;
/** Organization ID */
orgId: OrganizationId;
/** Whether to auto-enroll the user in account recovery (reset password) */
resetPasswordAutoEnroll: boolean;
/** The new master password */
newPassword: string;
/** Master password salt (typically the user's email) */
salt: MasterPasswordSalt;
}
/**
* Handles setting an initial password for an existing authed user.
*
@@ -95,4 +113,14 @@ export abstract class SetInitialPasswordService {
credentials: SetInitialPasswordTdeOffboardingCredentials,
userId: UserId,
) => Promise<void>;
/**
* Initializes a JIT-provisioned user's cryptographic state and enrolls them in master password unlock.
* @param credentials The credentials needed to initialize the JIT password user
* @param userId The account userId
*/
abstract initializePasswordJitPasswordUserV2Encryption(
credentials: InitializeJitPasswordCredentials,
userId: UserId,
): Promise<void>;
}

View File

@@ -108,7 +108,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/auth/send-access";
import { DefaultSendTokenService, SendTokenService } from "@bitwarden/common/auth/send-access";
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
@@ -131,10 +131,10 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
TwoFactorApiService,
DefaultTwoFactorApiService,
TwoFactorService,
DefaultTwoFactorService,
TwoFactorApiService,
TwoFactorService,
} from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
@@ -208,8 +208,8 @@ import { PinService } from "@bitwarden/common/key-management/pin/pin.service.imp
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import {
SendPasswordService,
DefaultSendPasswordService,
SendPasswordService,
} from "@bitwarden/common/key-management/sends";
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
import {
@@ -387,12 +387,12 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
// eslint-disable-next-line no-restricted-imports
import { PasswordRepromptService } from "@bitwarden/vault";
import {
DefaultVaultExportApiService,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
DefaultVaultExportApiService,
VaultExportApiService,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
VaultExportApiService,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
@@ -1583,6 +1583,7 @@ const safeProviders: SafeProvider[] = [
OrganizationUserApiService,
InternalUserDecryptionOptionsServiceAbstraction,
AccountCryptographicStateService,
RegisterSdkService,
],
}),
safeProvider({

View File

@@ -1,4 +1,8 @@
// Note: Nudge related code is exported from `libs/angular` because it is consumed by multiple
// `libs/*` packages. Exporting from the `libs/vault` package creates circular dependencies.
export { NudgesService, NudgeStatus, NudgeType } from "./services/nudges.service";
export { AUTOFILL_NUDGE_SERVICE } from "./services/nudge-injection-tokens";
export {
AUTOFILL_NUDGE_SERVICE,
AUTO_CONFIRM_NUDGE_SERVICE,
} from "./services/nudge-injection-tokens";
export { AutoConfirmNudgeService } from "./services/custom-nudges-services";

View File

@@ -0,0 +1,204 @@
# Custom Nudge Services
This folder contains custom implementations of `SingleNudgeService` that provide specialized logic for determining when nudges should be shown or dismissed.
## Architecture Overview
### Core Components
- **`NudgesService`** (`../nudges.service.ts`) - The main service that components use to check nudge status and dismiss nudges
- **`SingleNudgeService`** - Interface that all nudge services implement
- **`DefaultSingleNudgeService`** - Base implementation that stores dismissed state in user state
- **Custom nudge services** - Specialized implementations with additional logic
### How It Works
1. Components call `NudgesService.showNudgeSpotlight$()` or `showNudgeBadge$()` with a `NudgeType`
2. `NudgesService` routes to the appropriate custom nudge service (or falls back to `DefaultSingleNudgeService`)
3. The custom service returns a `NudgeStatus` indicating if the badge/spotlight should be shown
4. Custom services can combine the persisted dismissed state with dynamic conditions (e.g., account age, vault contents)
### NudgeStatus
```typescript
type NudgeStatus = {
hasBadgeDismissed: boolean; // True if the badge indicator should be hidden
hasSpotlightDismissed: boolean; // True if the spotlight/callout should be hidden
};
```
## Service Categories
### Universal Services
These services work on **all clients** (browser, web, desktop) and use `@Injectable({ providedIn: "root" })`.
| Service | Purpose |
| --------------------------------- | ---------------------------------------------------------------------- |
| `NewAccountNudgeService` | Auto-dismisses after account is 30 days old |
| `NewItemNudgeService` | Checks cipher counts for "add first item" nudges |
| `HasItemsNudgeService` | Checks if vault has items |
| `EmptyVaultNudgeService` | Checks empty vault state |
| `AccountSecurityNudgeService` | Checks security settings (PIN, biometrics) |
| `VaultSettingsImportNudgeService` | Checks import status |
| `NoOpNudgeService` | Always returns dismissed (used as fallback for client specific nudges) |
### Client-Specific Services
These services require **platform-specific features** and must be explicitly registered in each client that supports them.
| Service | Clients | Requires |
| ----------------------------- | ------------ | -------------------------------------- |
| `AutoConfirmNudgeService` | Browser only | `AutomaticUserConfirmationService` |
| `BrowserAutofillNudgeService` | Browser only | `BrowserApi` (lives in `apps/browser`) |
## Adding a New Nudge Service
### Step 1: Determine if Universal or Client-Specific
**Universal** - If your service only depends on:
- `StateProvider`
- Services available in all clients (e.g., `CipherService`, `OrganizationService`)
**Client-Specific** - If your service depends on:
- Browser APIs (`BrowserApi`, autofill services)
- Services only available in certain clients
- Platform-specific features
### Step 2: Create the Service
#### For Universal Services
```typescript
// my-nudge.service.ts
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@Injectable({ providedIn: "root" })
export class MyNudgeService extends DefaultSingleNudgeService {
constructor(
stateProvider: StateProvider,
private myDependency: MyDependency, // Must be available in all clients
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId), // Gets persisted dismissed state
this.myDependency.someData$,
]).pipe(
map(([persistedStatus, data]) => {
// Return dismissed if user already dismissed OR your condition is met
const autoDismiss = /* your logic */;
return {
hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
};
}),
);
}
}
```
#### For Client-Specific Services
```typescript
// my-client-specific-nudge.service.ts
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@Injectable() // NO providedIn: "root"
export class MyClientSpecificNudgeService extends DefaultSingleNudgeService {
constructor(
stateProvider: StateProvider,
private clientSpecificService: ClientSpecificService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([
this.getNudgeStatus$(nudgeType, userId),
this.clientSpecificService.someData$,
]).pipe(
map(([persistedStatus, data]) => {
const autoDismiss = /* your logic */;
return {
hasBadgeDismissed: persistedStatus.hasBadgeDismissed || autoDismiss,
hasSpotlightDismissed: persistedStatus.hasSpotlightDismissed || autoDismiss,
};
}),
);
}
}
```
### Step 3: Add NudgeType
Add your nudge type to `NudgeType` in `../nudges.service.ts`:
```typescript
export const NudgeType = {
// ... existing types
MyNewNudge: "my-new-nudge",
} as const;
```
### Step 4: Register in NudgesService
#### For Universal Services
Add to `customNudgeServices` map in `../nudges.service.ts`:
```typescript
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
// ... existing
[NudgeType.MyNewNudge]: inject(MyNudgeService),
};
```
#### For Client-Specific Services
1. **Add injection token** in `../nudge-injection-tokens.ts`:
```typescript
export const MY_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>("MyNudgeService");
```
2. **Inject with optional** in `../nudges.service.ts`:
```typescript
private myNudgeService = inject(MY_NUDGE_SERVICE, { optional: true });
private customNudgeServices = {
// ... existing
[NudgeType.MyNewNudge]: this.myNudgeService ?? this.noOpNudgeService,
};
```
3. **Register in each supporting client** (e.g., `apps/browser/src/popup/services/services.module.ts`):
```typescript
import { MY_NUDGE_SERVICE } from "@bitwarden/angular/vault";
safeProvider({
provide: MY_NUDGE_SERVICE as SafeInjectionToken<SingleNudgeService>,
useClass: MyClientSpecificNudgeService,
deps: [StateProvider, ClientSpecificService],
}),
```

View File

@@ -1,15 +1,24 @@
import { inject, Injectable } from "@angular/core";
import { Injectable } from "@angular/core";
import { combineLatest, map, Observable } from "rxjs";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/user-core";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeType, NudgeStatus } from "../nudges.service";
@Injectable({ providedIn: "root" })
/**
* Browser specific nudge service for auto-confirm nudge.
*/
@Injectable()
export class AutoConfirmNudgeService extends DefaultSingleNudgeService {
autoConfirmService = inject(AutomaticUserConfirmationService);
constructor(
stateProvider: StateProvider,
private autoConfirmService: AutomaticUserConfirmationService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return combineLatest([

View File

@@ -1,10 +1,11 @@
import { Injectable, inject } from "@angular/core";
import { Injectable } from "@angular/core";
import { Observable, combineLatest, from, map, of } from "rxjs";
import { catchError } from "rxjs/operators";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { UserId } from "@bitwarden/common/types/guid";
import { StateProvider } from "@bitwarden/state";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { NudgeStatus, NudgeType } from "../nudges.service";
@@ -18,8 +19,13 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
providedIn: "root",
})
export class NewAccountNudgeService extends DefaultSingleNudgeService {
vaultProfileService = inject(VaultProfileService);
logService = inject(LogService);
constructor(
stateProvider: StateProvider,
private vaultProfileService: VaultProfileService,
private logService: LogService,
) {
super(stateProvider);
}
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe(

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from "@angular/core";
import { Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -22,7 +22,11 @@ export interface SingleNudgeService {
providedIn: "root",
})
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected stateProvider: StateProvider;
constructor(stateProvider: StateProvider) {
this.stateProvider = stateProvider;
}
protected getNudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
return this.stateProvider

View File

@@ -2,6 +2,25 @@ import { InjectionToken } from "@angular/core";
import { SingleNudgeService } from "./default-single-nudge.service";
/**
* Injection tokens for client specific nudge services.
*
* These services require platform-specific features and must be explicitly
* provided by each client that supports them. If not provided, NudgesService
* falls back to NoOpNudgeService.
*
* Client specific services should use constructor injection (not inject())
* to maintain safeProvider type safety.
*
* Universal services use @Injectable({ providedIn: "root" }) and can use inject().
*/
/** Browser: Requires BrowserApi */
export const AUTOFILL_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutofillNudgeService",
);
/** Browser: Requires AutomaticUserConfirmationService */
export const AUTO_CONFIRM_NUDGE_SERVICE = new InjectionToken<SingleNudgeService>(
"AutoConfirmNudgeService",
);

View File

@@ -12,11 +12,10 @@ import {
NewItemNudgeService,
AccountSecurityNudgeService,
VaultSettingsImportNudgeService,
AutoConfirmNudgeService,
NoOpNudgeService,
} from "./custom-nudges-services";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
import { AUTOFILL_NUDGE_SERVICE } from "./nudge-injection-tokens";
import { AUTOFILL_NUDGE_SERVICE, AUTO_CONFIRM_NUDGE_SERVICE } from "./nudge-injection-tokens";
export type NudgeStatus = {
hasBadgeDismissed: boolean;
@@ -63,12 +62,21 @@ export class NudgesService {
// NoOp service that always returns dismissed
private noOpNudgeService = inject(NoOpNudgeService);
// Optional Browser-specific service provided via injection token (not all clients have autofill)
// Client specific services (optional, via injection tokens)
// These services require platform-specific features and fallback to NoOpNudgeService if not provided
private autofillNudgeService = inject(AUTOFILL_NUDGE_SERVICE, { optional: true });
private autoConfirmNudgeService = inject(AUTO_CONFIRM_NUDGE_SERVICE, { optional: true });
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
*
* NOTE: If a custom nudge service requires client specific services/features:
* 1. The custom nudge service must be provided via injection token and marked as optional.
* 2. The custom nudge service must be manually registered with that token in the client(s).
*
* See the README.md in the custom-nudge-services folder for more details on adding custom nudges.
* @private
*/
private customNudgeServices: Partial<Record<NudgeType, SingleNudgeService>> = {
@@ -84,7 +92,7 @@ export class NudgesService {
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
[NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
[NudgeType.AutoConfirmNudge]: this.autoConfirmNudgeService ?? this.noOpNudgeService,
};
/**

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",
@@ -46,6 +47,7 @@ export enum FeatureFlag {
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
/* Tools */
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
@@ -75,6 +77,7 @@ export enum FeatureFlag {
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
/* UIF */
RouterFocusManagement = "router-focus-management",
@@ -107,6 +110,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
@@ -153,6 +157,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
@@ -162,6 +167,7 @@ export const DefaultFeatureFlagValue = {
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: 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

@@ -1,5 +1,5 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-size-full">
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
<div class="tw-flex tw-size-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
@@ -24,6 +24,7 @@
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
[class.tw-rounded-tl-2xl]="rounded()"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>

View File

@@ -1,7 +1,7 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
@@ -38,6 +38,12 @@ export class LayoutComponent {
protected drawerPortal = inject(DrawerService).portal;
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
/**
* Rounded top left corner for the main content area
*/
readonly rounded = input(false, { transform: booleanAttribute });
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}

View File

@@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { LayoutComponent } from "./layout.component";
import { mockLayoutI18n } from "./mocks";
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
export default {
title: "Component Library/Layout",
component: LayoutComponent,
@@ -63,7 +65,7 @@ export const WithContent: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<bit-layout>
<bit-layout ${formatArgsForCodeSnippet<LayoutComponent>(args)}>
<bit-side-nav>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
@@ -111,3 +113,10 @@ export const Secondary: Story = {
`,
}),
};
export const Rounded: Story = {
...WithContent,
args: {
rounded: true,
},
};

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

Some files were not shown because too many files have changed in this diff Show More