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:
16
.github/workflows/test.yml
vendored
16
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Message, MessageTypes } from "./message";
|
||||
|
||||
const SENDER = "bitwarden-webauthn";
|
||||
@@ -23,7 +25,7 @@ type Handler = (
|
||||
* handling aborts and exceptions across separate execution contexts.
|
||||
*/
|
||||
export class Messenger {
|
||||
private messageEventListener: ((event: MessageEvent<MessageWithMetadata>) => void) | null = null;
|
||||
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
|
||||
private onDestroy = new EventTarget();
|
||||
|
||||
/**
|
||||
@@ -58,12 +60,6 @@ export class Messenger {
|
||||
this.broadcastChannel.addEventListener(this.messageEventListener);
|
||||
}
|
||||
|
||||
private stripMetadata({ SENDER, senderId, ...message }: MessageWithMetadata): Message {
|
||||
void SENDER;
|
||||
void senderId;
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the content script and returns the response.
|
||||
* AbortController signals will be forwarded to the content script.
|
||||
@@ -78,9 +74,7 @@ export class Messenger {
|
||||
|
||||
try {
|
||||
const promise = new Promise<Message>((resolve) => {
|
||||
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
|
||||
resolve(this.stripMetadata(event.data));
|
||||
};
|
||||
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => resolve(event.data);
|
||||
});
|
||||
|
||||
const abortListener = () =>
|
||||
@@ -135,9 +129,7 @@ export class Messenger {
|
||||
|
||||
try {
|
||||
const handlerResponse = await this.handler(message, abortController);
|
||||
if (handlerResponse !== undefined) {
|
||||
port.postMessage({ ...handlerResponse, SENDER });
|
||||
}
|
||||
port.postMessage({ ...handlerResponse, SENDER });
|
||||
} catch (error) {
|
||||
port.postMessage({
|
||||
SENDER,
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
||||
setTimeout(this.autofillOverlayContentService.refreshMenuLayerPosition, 100);
|
||||
}
|
||||
});
|
||||
|
||||
this.autofillOverlayContentService.refreshMenuLayerPosition();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -319,7 +319,10 @@ describe("PhishingDataService", () => {
|
||||
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net");
|
||||
|
||||
await service["_loadBlobToMemory"]();
|
||||
// Trigger the load pipeline and allow async RxJS processing to complete
|
||||
service["_loadBlobToMemory"]();
|
||||
await flushPromises();
|
||||
|
||||
const set = service["_webAddressesSet"] as Set<string>;
|
||||
expect(set).toBeDefined();
|
||||
expect(set.has("phish.com")).toBe(true);
|
||||
|
||||
@@ -3,11 +3,15 @@ import {
|
||||
EMPTY,
|
||||
first,
|
||||
firstValueFrom,
|
||||
from,
|
||||
of,
|
||||
share,
|
||||
takeUntil,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
map,
|
||||
} from "rxjs";
|
||||
|
||||
import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags";
|
||||
@@ -64,12 +68,20 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition<string>(
|
||||
|
||||
/** Coordinates fetching, caching, and patching of known phishing web addresses */
|
||||
export class PhishingDataService {
|
||||
// While background scripts do not necessarily need destroying,
|
||||
// processes in PhishingDataService are memory intensive.
|
||||
// We are adding the destroy to guard against accidental leaks.
|
||||
private _destroy$ = new Subject<void>();
|
||||
|
||||
private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod
|
||||
private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY);
|
||||
private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY);
|
||||
|
||||
// In-memory set loaded from blob for fast lookups without reading large storage repeatedly
|
||||
private _webAddressesSet: Set<string> | null = null;
|
||||
// Loading variables for web addresses set
|
||||
// Triggers a load for _webAddressesSet
|
||||
private _loadTrigger$ = new Subject<void>();
|
||||
|
||||
// How often are new web addresses added to the remote?
|
||||
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -81,6 +93,10 @@ export class PhishingDataService {
|
||||
this._phishingMetaState.state$.pipe(
|
||||
first(), // Only take the first value to avoid an infinite loop when updating the cache below
|
||||
tap((metaState) => {
|
||||
// Initial loading of web addresses set if not already loaded
|
||||
if (!this._webAddressesSet) {
|
||||
this._loadBlobToMemory();
|
||||
}
|
||||
// Perform any updates in the background if needed
|
||||
void this._backgroundUpdate(metaState);
|
||||
}),
|
||||
@@ -90,6 +106,8 @@ export class PhishingDataService {
|
||||
}),
|
||||
),
|
||||
),
|
||||
// Stop emitting when dispose() is called
|
||||
takeUntil(this._destroy$),
|
||||
share(),
|
||||
);
|
||||
|
||||
@@ -109,7 +127,18 @@ export class PhishingDataService {
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
this.UPDATE_INTERVAL_DURATION,
|
||||
);
|
||||
void this._loadBlobToMemory();
|
||||
this._setupLoadPipeline();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// Signal all pipelines to stop and unsubscribe stored subscriptions
|
||||
this._destroy$.next();
|
||||
this._destroy$.complete();
|
||||
|
||||
// Clear web addresses set from memory
|
||||
if (this._webAddressesSet !== null) {
|
||||
this._webAddressesSet = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,7 +298,7 @@ export class PhishingDataService {
|
||||
}
|
||||
if (next.blob) {
|
||||
await this._phishingBlobState.update(() => next!.blob!);
|
||||
await this._loadBlobToMemory();
|
||||
this._loadBlobToMemory();
|
||||
}
|
||||
|
||||
// Performance logging
|
||||
@@ -293,6 +322,47 @@ export class PhishingDataService {
|
||||
}
|
||||
}
|
||||
|
||||
// Sets up the load pipeline to load the blob into memory when triggered
|
||||
private _setupLoadPipeline(): void {
|
||||
this._loadTrigger$
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this._phishingBlobState.state$.pipe(
|
||||
first(),
|
||||
switchMap((blobBase64) => {
|
||||
if (!blobBase64) {
|
||||
return of(undefined);
|
||||
}
|
||||
// Note: _decompressString wraps a promise that cannot be aborted
|
||||
// If performance improvements are needed, consider migrating to a cancellable approach
|
||||
return from(this._decompressString(blobBase64)).pipe(
|
||||
map((text) => {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const newWebAddressesSet = new Set(lines);
|
||||
this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a));
|
||||
this._webAddressesSet = new Set(newWebAddressesSet);
|
||||
this.logService.info(
|
||||
`[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error("[PhishingDataService] Failed to load blob into memory", err);
|
||||
return of(undefined);
|
||||
}),
|
||||
),
|
||||
),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error("[PhishingDataService] Load pipeline failed", err);
|
||||
return of(undefined);
|
||||
}),
|
||||
takeUntil(this._destroy$),
|
||||
share(),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// [FIXME] Move compression helpers to a shared utils library
|
||||
// to separate from phishing data service.
|
||||
// ------------------------- Blob and Compression Handling -------------------------
|
||||
@@ -337,29 +407,9 @@ export class PhishingDataService {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load compressed newline blob into an in-memory Set for fast lookups
|
||||
private async _loadBlobToMemory(): Promise<void> {
|
||||
this.logService.debug("[PhishingDataService] Loading data blob into memory...");
|
||||
try {
|
||||
const blobBase64 = await firstValueFrom(this._phishingBlobState.state$);
|
||||
if (!blobBase64) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await this._decompressString(blobBase64);
|
||||
// Split and filter
|
||||
const lines = text.split(/\r?\n/);
|
||||
const newWebAddressesSet = new Set(lines);
|
||||
|
||||
// Add test addresses
|
||||
this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a));
|
||||
this._webAddressesSet = new Set(newWebAddressesSet);
|
||||
this.logService.info(
|
||||
`[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Failed to load blob into memory", err);
|
||||
}
|
||||
// Trigger a load of the blob into memory
|
||||
private _loadBlobToMemory(): void {
|
||||
this._loadTrigger$.next();
|
||||
}
|
||||
private _uint8ToBase64Fallback(bytes: Uint8Array): string {
|
||||
const CHUNK_SIZE = 0x8000; // 32KB chunks
|
||||
|
||||
@@ -137,6 +137,9 @@ export class PhishingDetectionService {
|
||||
|
||||
this._didInit = true;
|
||||
return () => {
|
||||
// Dispose phishing data service resources
|
||||
phishingDataService.dispose();
|
||||
|
||||
initSub.unsubscribe();
|
||||
this._didInit = false;
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import { ReadableStream as NodeReadableStream } from "stream/web";
|
||||
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { PhishingIndexedDbService } from "./phishing-indexeddb.service";
|
||||
|
||||
describe("PhishingIndexedDbService", () => {
|
||||
let service: PhishingIndexedDbService;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
// Mock IndexedDB storage (keyed by URL for row-per-URL storage)
|
||||
let mockStore: Map<string, { url: string }>;
|
||||
let mockObjectStore: any;
|
||||
let mockTransaction: any;
|
||||
let mockDb: any;
|
||||
let mockOpenRequest: any;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock<LogService>();
|
||||
mockStore = new Map();
|
||||
|
||||
// Mock IDBObjectStore
|
||||
mockObjectStore = {
|
||||
put: jest.fn().mockImplementation((record: { url: string }) => {
|
||||
const request = {
|
||||
error: null as DOMException | null,
|
||||
result: undefined as undefined,
|
||||
onsuccess: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
};
|
||||
setTimeout(() => {
|
||||
mockStore.set(record.url, record);
|
||||
request.onsuccess?.();
|
||||
}, 0);
|
||||
return request;
|
||||
}),
|
||||
get: jest.fn().mockImplementation((key: string) => {
|
||||
const request = {
|
||||
error: null as DOMException | null,
|
||||
result: mockStore.get(key),
|
||||
onsuccess: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
};
|
||||
setTimeout(() => {
|
||||
request.result = mockStore.get(key);
|
||||
request.onsuccess?.();
|
||||
}, 0);
|
||||
return request;
|
||||
}),
|
||||
clear: jest.fn().mockImplementation(() => {
|
||||
const request = {
|
||||
error: null as DOMException | null,
|
||||
result: undefined as undefined,
|
||||
onsuccess: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
};
|
||||
setTimeout(() => {
|
||||
mockStore.clear();
|
||||
request.onsuccess?.();
|
||||
}, 0);
|
||||
return request;
|
||||
}),
|
||||
openCursor: jest.fn().mockImplementation(() => {
|
||||
const entries = Array.from(mockStore.entries());
|
||||
let index = 0;
|
||||
const request = {
|
||||
error: null as DOMException | null,
|
||||
result: null as any,
|
||||
onsuccess: null as ((e: any) => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
};
|
||||
const advanceCursor = () => {
|
||||
if (index < entries.length) {
|
||||
const [, value] = entries[index];
|
||||
index++;
|
||||
request.result = {
|
||||
value,
|
||||
continue: () => setTimeout(advanceCursor, 0),
|
||||
};
|
||||
} else {
|
||||
request.result = null;
|
||||
}
|
||||
request.onsuccess?.({ target: request });
|
||||
};
|
||||
setTimeout(advanceCursor, 0);
|
||||
return request;
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock IDBTransaction
|
||||
mockTransaction = {
|
||||
objectStore: jest.fn().mockReturnValue(mockObjectStore),
|
||||
oncomplete: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
};
|
||||
|
||||
// Trigger oncomplete after a tick
|
||||
const originalObjectStore = mockTransaction.objectStore;
|
||||
mockTransaction.objectStore = jest.fn().mockImplementation((...args: any[]) => {
|
||||
setTimeout(() => mockTransaction.oncomplete?.(), 0);
|
||||
return originalObjectStore(...args);
|
||||
});
|
||||
|
||||
// Mock IDBDatabase
|
||||
mockDb = {
|
||||
transaction: jest.fn().mockReturnValue(mockTransaction),
|
||||
close: jest.fn(),
|
||||
objectStoreNames: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
createObjectStore: jest.fn(),
|
||||
};
|
||||
|
||||
// Mock IDBOpenDBRequest
|
||||
mockOpenRequest = {
|
||||
error: null as DOMException | null,
|
||||
result: mockDb,
|
||||
onsuccess: null as (() => void) | null,
|
||||
onerror: null as (() => void) | null,
|
||||
onupgradeneeded: null as ((event: any) => void) | null,
|
||||
};
|
||||
|
||||
// Mock indexedDB.open
|
||||
const mockIndexedDB = {
|
||||
open: jest.fn().mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onsuccess?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
}),
|
||||
};
|
||||
|
||||
global.indexedDB = mockIndexedDB as any;
|
||||
|
||||
service = new PhishingIndexedDbService(logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete (global as any).indexedDB;
|
||||
});
|
||||
|
||||
describe("saveUrls", () => {
|
||||
it("stores URLs in IndexedDB and returns true", async () => {
|
||||
const urls = ["https://phishing.com", "https://malware.net"];
|
||||
|
||||
const result = await service.saveUrls(urls);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite");
|
||||
expect(mockObjectStore.clear).toHaveBeenCalled();
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty array", async () => {
|
||||
const result = await service.saveUrls([]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockObjectStore.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace from URLs", async () => {
|
||||
const urls = [" https://example.com ", "\nhttps://test.org\n"];
|
||||
|
||||
await service.saveUrls(urls);
|
||||
|
||||
expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" });
|
||||
expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" });
|
||||
});
|
||||
|
||||
it("skips empty lines", async () => {
|
||||
const urls = ["https://example.com", "", " ", "https://test.org"];
|
||||
|
||||
await service.saveUrls(urls);
|
||||
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("handles duplicate URLs via upsert (keyPath deduplication)", async () => {
|
||||
const urls = [
|
||||
"https://example.com",
|
||||
"https://example.com", // duplicate
|
||||
"https://test.org",
|
||||
];
|
||||
|
||||
const result = await service.saveUrls(urls);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// put() is called 3 times, but mockStore (using Map with URL as key)
|
||||
// only stores 2 unique entries - demonstrating upsert behavior
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(3);
|
||||
expect(mockStore.size).toBe(2);
|
||||
});
|
||||
|
||||
it("logs error and returns false on failure", async () => {
|
||||
const error = new Error("IndexedDB error");
|
||||
mockOpenRequest.error = error;
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onerror?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
const result = await service.saveUrls(["https://test.com"]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Save failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUrl", () => {
|
||||
it("returns true for existing URL", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
|
||||
const result = await service.hasUrl("https://example.com");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly");
|
||||
expect(mockObjectStore.get).toHaveBeenCalledWith("https://example.com");
|
||||
});
|
||||
|
||||
it("returns false for non-existing URL", async () => {
|
||||
const result = await service.hasUrl("https://notfound.com");
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const error = new Error("IndexedDB error");
|
||||
mockOpenRequest.error = error;
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onerror?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
const result = await service.hasUrl("https://example.com");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Check failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllUrls", () => {
|
||||
it("loads all URLs using cursor", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
mockStore.set("https://test.org", { url: "https://test.org" });
|
||||
|
||||
const result = await service.loadAllUrls();
|
||||
|
||||
expect(result).toContain("https://example.com");
|
||||
expect(result).toContain("https://test.org");
|
||||
expect(result.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no data exists", async () => {
|
||||
const result = await service.loadAllUrls();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on error", async () => {
|
||||
const error = new Error("IndexedDB error");
|
||||
mockOpenRequest.error = error;
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onerror?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
const result = await service.loadAllUrls();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Load failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveUrlsFromStream", () => {
|
||||
it("saves URLs from stream", async () => {
|
||||
const content = "https://example.com\nhttps://test.org\nhttps://phishing.net";
|
||||
const stream = new NodeReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(content));
|
||||
controller.close();
|
||||
},
|
||||
}) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
const result = await service.saveUrlsFromStream(stream);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockObjectStore.clear).toHaveBeenCalled();
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("handles chunked stream data", async () => {
|
||||
const content = "https://url1.com\nhttps://url2.com";
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = encoder.encode(content);
|
||||
|
||||
// Split into multiple small chunks
|
||||
const stream = new NodeReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoded.slice(0, 5));
|
||||
controller.enqueue(encoded.slice(5, 10));
|
||||
controller.enqueue(encoded.slice(10));
|
||||
controller.close();
|
||||
},
|
||||
}) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
const result = await service.saveUrlsFromStream(stream);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns false on error", async () => {
|
||||
const error = new Error("IndexedDB error");
|
||||
mockOpenRequest.error = error;
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onerror?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
const stream = new NodeReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("https://test.com"));
|
||||
controller.close();
|
||||
},
|
||||
}) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
const result = await service.saveUrlsFromStream(stream);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Stream save failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("database initialization", () => {
|
||||
it("creates object store with keyPath on upgrade", async () => {
|
||||
mockDb.objectStoreNames.contains.mockReturnValue(false);
|
||||
|
||||
(global.indexedDB.open as jest.Mock).mockImplementation(() => {
|
||||
setTimeout(() => {
|
||||
mockOpenRequest.onupgradeneeded?.({ target: mockOpenRequest });
|
||||
mockOpenRequest.onsuccess?.();
|
||||
}, 0);
|
||||
return mockOpenRequest;
|
||||
});
|
||||
|
||||
await service.hasUrl("https://test.com");
|
||||
|
||||
expect(mockDb.createObjectStore).toHaveBeenCalledWith("phishing-urls", { keyPath: "url" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
/**
|
||||
* Record type for phishing URL storage in IndexedDB.
|
||||
*/
|
||||
type PhishingUrlRecord = { url: string };
|
||||
|
||||
/**
|
||||
* IndexedDB storage service for phishing URLs.
|
||||
* Stores URLs as individual rows.
|
||||
*/
|
||||
export class PhishingIndexedDbService {
|
||||
private readonly DB_NAME = "bitwarden-phishing";
|
||||
private readonly STORE_NAME = "phishing-urls";
|
||||
private readonly DB_VERSION = 1;
|
||||
private readonly CHUNK_SIZE = 50000;
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
/**
|
||||
* Opens the IndexedDB database, creating the object store if needed.
|
||||
*/
|
||||
private openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(this.DB_NAME, this.DB_VERSION);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = (e.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
|
||||
db.createObjectStore(this.STORE_NAME, { keyPath: "url" });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all records from the phishing URLs store.
|
||||
*/
|
||||
private clearStore(db: IDBDatabase): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(this.STORE_NAME, "readwrite").objectStore(this.STORE_NAME).clear();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an array of phishing URLs to IndexedDB.
|
||||
* Atomically replaces all existing data.
|
||||
*
|
||||
* @param urls - Array of phishing URLs to save
|
||||
* @returns `true` if save succeeded, `false` on error
|
||||
*/
|
||||
async saveUrls(urls: string[]): Promise<boolean> {
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
await this.clearStore(db);
|
||||
await this.saveChunked(db, urls);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Save failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves URLs in chunks to prevent transaction timeouts and UI freezes.
|
||||
*/
|
||||
private async saveChunked(db: IDBDatabase, urls: string[]): Promise<void> {
|
||||
const cleaned = urls.map((u) => u.trim()).filter(Boolean);
|
||||
for (let i = 0; i < cleaned.length; i += this.CHUNK_SIZE) {
|
||||
await this.saveChunk(db, cleaned.slice(i, i + this.CHUNK_SIZE));
|
||||
await new Promise((r) => setTimeout(r, 0)); // Yield to event loop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a single chunk of URLs in one transaction.
|
||||
*/
|
||||
private saveChunk(db: IDBDatabase, urls: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(this.STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(this.STORE_NAME);
|
||||
for (const url of urls) {
|
||||
store.put({ url } as PhishingUrlRecord);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL exists in the phishing database.
|
||||
*
|
||||
* @param url - The URL to check
|
||||
* @returns `true` if URL exists, `false` if not found or on error
|
||||
*/
|
||||
async hasUrl(url: string): Promise<boolean> {
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
return await this.checkUrlExists(db, url);
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Check failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual URL existence check using index lookup.
|
||||
*/
|
||||
private checkUrlExists(db: IDBDatabase, url: string): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(this.STORE_NAME, "readonly");
|
||||
const req = tx.objectStore(this.STORE_NAME).get(url);
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve(req.result !== undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all phishing URLs from IndexedDB.
|
||||
*
|
||||
* @returns Array of all stored URLs, or empty array on error
|
||||
*/
|
||||
async loadAllUrls(): Promise<string[]> {
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
return await this.getAllUrls(db);
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Load failed", error);
|
||||
return [];
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates all records using a cursor.
|
||||
*/
|
||||
private getAllUrls(db: IDBDatabase): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urls: string[] = [];
|
||||
const req = db
|
||||
.transaction(this.STORE_NAME, "readonly")
|
||||
.objectStore(this.STORE_NAME)
|
||||
.openCursor();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = (e) => {
|
||||
const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
|
||||
if (cursor) {
|
||||
urls.push((cursor.value as PhishingUrlRecord).url);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(urls);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves phishing URLs directly from a stream.
|
||||
* Processes data incrementally to minimize memory usage.
|
||||
*
|
||||
* @param stream - ReadableStream of newline-delimited URLs
|
||||
* @returns `true` if save succeeded, `false` on error
|
||||
*/
|
||||
async saveUrlsFromStream(stream: ReadableStream<Uint8Array>): Promise<boolean> {
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
await this.clearStore(db);
|
||||
await this.processStream(db, stream);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Stream save failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a stream of URL data, parsing lines and saving in chunks.
|
||||
*/
|
||||
private async processStream(db: IDBDatabase, stream: ReadableStream<Uint8Array>): Promise<void> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let urls: string[] = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
// Decode BEFORE done check; stream: !done flushes on final call
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
|
||||
if (done) {
|
||||
// Split remaining buffer by newlines in case it contains multiple URLs
|
||||
const lines = buffer.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (urls.length > 0) {
|
||||
await this.saveChunk(db, urls);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
|
||||
if (urls.length >= this.CHUNK_SIZE) {
|
||||
await this.saveChunk(db, urls);
|
||||
urls = [];
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createChromeTabMock } from "../../autofill/spec/autofill-mocks";
|
||||
|
||||
import { BrowserApi } from "./browser-api";
|
||||
import BrowserPopupUtils from "./browser-popup-utils";
|
||||
import BrowserPopupUtils, { PopupWidthOptions } from "./browser-popup-utils";
|
||||
|
||||
describe("BrowserPopupUtils", () => {
|
||||
afterEach(() => {
|
||||
@@ -152,7 +152,7 @@ describe("BrowserPopupUtils", () => {
|
||||
focused: false,
|
||||
alwaysOnTop: false,
|
||||
incognito: false,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
});
|
||||
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
|
||||
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
|
||||
@@ -168,7 +168,7 @@ describe("BrowserPopupUtils", () => {
|
||||
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
|
||||
type: "popup",
|
||||
focused: true,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
height: 630,
|
||||
left: 85,
|
||||
top: 190,
|
||||
@@ -197,7 +197,7 @@ describe("BrowserPopupUtils", () => {
|
||||
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
|
||||
type: "popup",
|
||||
focused: true,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
height: 630,
|
||||
left: 85,
|
||||
top: 190,
|
||||
@@ -214,7 +214,7 @@ describe("BrowserPopupUtils", () => {
|
||||
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
|
||||
type: "popup",
|
||||
focused: true,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
height: 630,
|
||||
left: 85,
|
||||
top: 190,
|
||||
@@ -267,7 +267,7 @@ describe("BrowserPopupUtils", () => {
|
||||
expect(BrowserApi.createWindow).toHaveBeenCalledWith({
|
||||
type: "popup",
|
||||
focused: true,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
height: 630,
|
||||
left: 85,
|
||||
top: 190,
|
||||
@@ -290,7 +290,7 @@ describe("BrowserPopupUtils", () => {
|
||||
focused: false,
|
||||
alwaysOnTop: false,
|
||||
incognito: false,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
state: "fullscreen",
|
||||
});
|
||||
jest
|
||||
@@ -321,7 +321,7 @@ describe("BrowserPopupUtils", () => {
|
||||
focused: false,
|
||||
alwaysOnTop: false,
|
||||
incognito: false,
|
||||
width: 380,
|
||||
width: PopupWidthOptions.default,
|
||||
state: "fullscreen",
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ import { BrowserApi } from "./browser-api";
|
||||
* Value represents width in pixels
|
||||
*/
|
||||
export const PopupWidthOptions = Object.freeze({
|
||||
default: 380,
|
||||
wide: 480,
|
||||
"extra-wide": 600,
|
||||
default: 480,
|
||||
wide: 600,
|
||||
narrow: 380,
|
||||
});
|
||||
|
||||
type PopupWidthOptions = typeof PopupWidthOptions;
|
||||
|
||||
@@ -44,7 +44,7 @@ import { PopupTabNavigationComponent } from "./popup-tab-navigation.component";
|
||||
@Component({
|
||||
selector: "extension-container",
|
||||
template: `
|
||||
<div class="tw-h-[640px] tw-w-[380px] tw-border tw-border-solid tw-border-secondary-300">
|
||||
<div class="tw-h-[640px] tw-w-[480px] tw-border tw-border-solid tw-border-secondary-300">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
`,
|
||||
@@ -678,7 +678,7 @@ export const WidthOptions: Story = {
|
||||
template: /* HTML */ `
|
||||
<div class="tw-flex tw-flex-col tw-gap-4 tw-text-main">
|
||||
<div>Default:</div>
|
||||
<div class="tw-h-[640px] tw-w-[380px] tw-border tw-border-solid tw-border-secondary-300">
|
||||
<div class="tw-h-[640px] tw-w-[480px] tw-border tw-border-solid tw-border-secondary-300">
|
||||
<mock-vault-page></mock-vault-page>
|
||||
</div>
|
||||
<div>Wide:</div>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class PopupSizeService {
|
||||
}
|
||||
const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default;
|
||||
|
||||
document.body.style.minWidth = `${pxWidth}px`;
|
||||
document.body.style.width = `${pxWidth}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { LockIcon, RegistrationCheckEmailIcon } from "@bitwarden/assets/svg";
|
||||
import { PopupWidthOptions } from "@bitwarden/browser/platform/browser/browser-popup-utils";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
@@ -243,7 +244,12 @@ export const DefaultContentExample: Story = {
|
||||
}),
|
||||
parameters: {
|
||||
chromatic: {
|
||||
viewports: [380, 1280],
|
||||
viewports: [
|
||||
PopupWidthOptions.default,
|
||||
PopupWidthOptions.narrow,
|
||||
PopupWidthOptions.wide,
|
||||
1280,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
width: 480px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-height: inherit;
|
||||
@@ -84,9 +84,9 @@
|
||||
animation: redraw 1s linear infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Text selection style:
|
||||
* suppress user selection for most elements (to make it more app-like)
|
||||
* suppress user selection for most elements (to make it more app-like)
|
||||
*/
|
||||
h1,
|
||||
h2,
|
||||
@@ -165,7 +165,7 @@
|
||||
@apply tw-text-muted;
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Text selection style:
|
||||
* Set explicit selection styles (assumes primary accent color has sufficient
|
||||
* contrast against the background, so its inversion is also still readable)
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -79,7 +79,7 @@ export class AppearanceV2Component implements OnInit {
|
||||
protected readonly widthOptions: Option<PopupWidthOption>[] = [
|
||||
{ label: this.i18nService.t("default"), value: "default" },
|
||||
{ label: this.i18nService.t("wide"), value: "wide" },
|
||||
{ label: this.i18nService.t("extraWide"), value: "extra-wide" },
|
||||
{ label: this.i18nService.t("narrow"), value: "narrow" },
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -115,15 +115,22 @@ export class TrashListItemsContainerComponent {
|
||||
}
|
||||
|
||||
async restore(cipher: PopupCipherViewLike) {
|
||||
let toastMessage;
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherService.restoreWithServer(cipher.id as string, activeUserId);
|
||||
|
||||
if (cipher.archivedDate) {
|
||||
toastMessage = this.i18nService.t("archivedItemRestored");
|
||||
} else {
|
||||
toastMessage = this.i18nService.t("restoredItem");
|
||||
}
|
||||
|
||||
await this.router.navigate(["/trash"]);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("restoredItem"),
|
||||
message: toastMessage,
|
||||
});
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
|
||||
@@ -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
|
||||
|
||||
324
apps/desktop/desktop_native/autotype/tests/integration_tests.rs
Normal file
324
apps/desktop/desktop_native/autotype/tests/integration_tests.rs
Normal 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));
|
||||
}
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2092,6 +2092,9 @@
|
||||
"permanentlyDeletedItem": {
|
||||
"message": "Item permanently deleted"
|
||||
},
|
||||
"archivedItemRestored": {
|
||||
"message": "Archived item restored"
|
||||
},
|
||||
"restoredItem": {
|
||||
"message": "Item restored"
|
||||
},
|
||||
|
||||
29
apps/desktop/src/scss/migration.scss
Normal file
29
apps/desktop/src/scss/migration.scss
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./auto-confirm-edit-policy-dialog.component";
|
||||
export * from "./organization-data-ownership-edit-policy-dialog.component";
|
||||
export * from "./models";
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -31,81 +31,75 @@
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<tr bitRow>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</tr>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</ng-container>
|
||||
<tbody>
|
||||
<ng-template body let-rows$>
|
||||
<tr bitRow *ngFor="let r of rows$ | async">
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="r"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(r)"
|
||||
title="{{ 'editItemWithName' | i18n: r.name }}"
|
||||
>{{ r.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ r.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && r.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="r.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ r.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="r.organizationId"
|
||||
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<a
|
||||
bitBadge
|
||||
href="{{ cipherDocs.get(r.id) }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
*ngIf="cipherDocs.has(r.id)"
|
||||
>
|
||||
{{ "instructions" | i18n }}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</tbody></bit-table
|
||||
>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
<td bitCell class="tw-text-right">
|
||||
<a
|
||||
bitBadge
|
||||
href="{{ cipherDocs.get(row.id) }}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
*ngIf="cipherDocs.has(row.id)"
|
||||
>
|
||||
{{ "instructions" | i18n }}</a
|
||||
>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
</bit-container>
|
||||
|
||||
@@ -32,68 +32,63 @@
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<tr bitRow>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
<th bitCell></th>
|
||||
</tr>
|
||||
<th bitCell></th>
|
||||
<th bitCell>{{ "name" | i18n }}</th>
|
||||
<th bitCell>{{ "owner" | i18n }}</th>
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr bitRow *ngFor="let r of rows$ | async">
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="r"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(r); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(r)"
|
||||
title="{{ 'editItemWithName' | i18n: r.name }}"
|
||||
>{{ r.name }}</a
|
||||
>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ r.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && r.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="r.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ r.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="r.organizationId"
|
||||
[organizationName]="r.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell>
|
||||
<app-vault-icon [cipher]="row"></app-vault-icon>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<ng-container *ngIf="!organization || canManageCipher(row); else cantManage">
|
||||
<a
|
||||
bitLink
|
||||
href="#"
|
||||
appStopClick
|
||||
(click)="selectCipher(row)"
|
||||
title="{{ 'editItemWithName' | i18n: row.name }}"
|
||||
>{{ row.name }}</a
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-container>
|
||||
<ng-template #cantManage>
|
||||
<span>{{ row.name }}</span>
|
||||
</ng-template>
|
||||
<ng-container *ngIf="!organization && row.organizationId">
|
||||
<i
|
||||
class="bwi bwi-collection-shared tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'shared' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "shared" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="row.hasAttachments">
|
||||
<i
|
||||
class="bwi bwi-paperclip tw-ml-1"
|
||||
appStopProp
|
||||
title="{{ 'attachments' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "attachments" | i18n }}</span>
|
||||
</ng-container>
|
||||
<br />
|
||||
<small>{{ row.subTitle }}</small>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<app-org-badge
|
||||
*ngIf="!organization"
|
||||
[disabled]="disabled"
|
||||
[organizationId]="row.organizationId"
|
||||
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
|
||||
appStopProp
|
||||
>
|
||||
</app-org-badge>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table>
|
||||
</bit-table-scroll>
|
||||
</ng-container>
|
||||
</div>
|
||||
</bit-container>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
|
||||
<div class="tw-w-full">
|
||||
<a bitButton routerLink="./" *ngIf="!homepage">
|
||||
{{ "backToReports" | i18n }}
|
||||
</a>
|
||||
@if (!homepage) {
|
||||
<a bitButton routerLink="./">
|
||||
{{ "backToReports" | i18n }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, OnDestroy } from "@angular/core";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { Subscription } from "rxjs";
|
||||
import { filter } from "rxjs/operators";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
@@ -10,20 +10,20 @@ import { filter } from "rxjs/operators";
|
||||
templateUrl: "reports-layout.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class ReportsLayoutComponent implements OnDestroy {
|
||||
export class ReportsLayoutComponent {
|
||||
homepage = true;
|
||||
subscription: Subscription;
|
||||
|
||||
constructor(router: Router) {
|
||||
this.subscription = router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
const reportsHomeRoute = "/reports";
|
||||
|
||||
this.homepage = router.url === reportsHomeRoute;
|
||||
router.events
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
this.homepage = (event as NavigationEnd).url == "/reports";
|
||||
this.homepage = (event as NavigationEnd).url == reportsHomeRoute;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscription?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,37 +171,47 @@
|
||||
|
||||
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
|
||||
|
||||
@if (!viewingOrgVault) {
|
||||
<button bitMenuItem type="button" *ngIf="showFavorite" (click)="toggleFavorite()">
|
||||
@if (showFavorite) {
|
||||
<button bitMenuItem type="button" (click)="toggleFavorite()">
|
||||
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
|
||||
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button bitMenuItem type="button" (click)="editCipher()" *ngIf="canEditCipher">
|
||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||
{{ "edit" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
|
||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||
{{ "attachments" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showClone" type="button" (click)="clone()">
|
||||
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="showAssignToCollections"
|
||||
type="button"
|
||||
(click)="assignToCollections()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem *ngIf="showEventLogs" type="button" (click)="events()">
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
@if (!isDeleted && canEditCipher) {
|
||||
<button bitMenuItem type="button" (click)="editCipher()">
|
||||
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
|
||||
{{ "edit" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (showAttachments) {
|
||||
<button bitMenuItem type="button" (click)="attachments()">
|
||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||
{{ "attachments" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (showClone) {
|
||||
<button bitMenuItem type="button" (click)="clone()">
|
||||
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (showAssignToCollections) {
|
||||
<button
|
||||
bitMenuItem
|
||||
*ngIf="showAssignToCollections"
|
||||
type="button"
|
||||
(click)="assignToCollections()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (showEventLogs) {
|
||||
<button bitMenuItem type="button" (click)="events()">
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (showArchiveButton) {
|
||||
@if (userCanArchive) {
|
||||
<button bitMenuItem (click)="archive()" type="button">
|
||||
|
||||
@@ -161,7 +161,9 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return false;
|
||||
}
|
||||
|
||||
return CipherViewLikeUtils.isArchived(this.cipher);
|
||||
return (
|
||||
CipherViewLikeUtils.isArchived(this.cipher) && !CipherViewLikeUtils.isDeleted(this.cipher)
|
||||
);
|
||||
}
|
||||
|
||||
protected get clickAction() {
|
||||
@@ -191,7 +193,7 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
// Do not show attachments button if:
|
||||
// item is archived AND user is not premium user
|
||||
protected get showAttachments() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
|
||||
if ((CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) || this.isDeleted) {
|
||||
return false;
|
||||
}
|
||||
return this.canEditCipher || this.hasAttachments;
|
||||
@@ -387,7 +389,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
}
|
||||
|
||||
protected get showFavorite() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
|
||||
if (
|
||||
(!this.viewingOrgVault &&
|
||||
CipherViewLikeUtils.isArchived(this.cipher) &&
|
||||
!this.userCanArchive) ||
|
||||
CipherViewLikeUtils.isDeleted(this.cipher)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -84,20 +84,19 @@
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
|
||||
<button *ngIf="bulkArchiveAllowed" type="button" bitMenuItem (click)="bulkArchive()">
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
@if (bulkArchiveAllowed) {
|
||||
<button type="button" bitMenuItem (click)="bulkArchive()">
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
*ngIf="bulkUnarchiveAllowed"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkUnarchive()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
@if (bulkUnarchiveAllowed) {
|
||||
<button type="button" bitMenuItem (click)="bulkUnarchive()">
|
||||
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
*ngIf="canRestoreSelected$ | async"
|
||||
|
||||
@@ -277,7 +277,12 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
|
||||
get bulkArchiveAllowed() {
|
||||
const hasCollectionsSelected = this.selection.selected.some((item) => item.collection);
|
||||
if (this.selection.selected.length === 0 || !this.userCanArchive || hasCollectionsSelected) {
|
||||
if (
|
||||
this.selection.selected.length === 0 ||
|
||||
!this.userCanArchive ||
|
||||
hasCollectionsSelected ||
|
||||
this.showBulkTrashOptions
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -291,7 +296,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
|
||||
// Bulk Unarchive button should appear for Archive vault even if user does not have archive permissions
|
||||
get bulkUnarchiveAllowed() {
|
||||
if (this.selection.selected.length === 0) {
|
||||
if (this.selection.selected.length === 0 || this.showBulkTrashOptions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1271,6 +1271,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}
|
||||
|
||||
restore = async (c: C): Promise<boolean> => {
|
||||
let toastMessage;
|
||||
if (!CipherViewLikeUtils.isDeleted(c)) {
|
||||
return;
|
||||
}
|
||||
@@ -1284,13 +1285,19 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
return;
|
||||
}
|
||||
|
||||
if (CipherViewLikeUtils.isArchived(c)) {
|
||||
toastMessage = this.i18nService.t("archivedItemRestored");
|
||||
} else {
|
||||
toastMessage = this.i18nService.t("restoredItem");
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
await this.cipherService.restoreWithServer(uuidAsString(c.id), activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("restoredItem"),
|
||||
message: toastMessage,
|
||||
});
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
@@ -1299,11 +1306,18 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
};
|
||||
|
||||
async bulkRestore(ciphers: C[]) {
|
||||
let toastMessage;
|
||||
if (ciphers.some((c) => !c.edit)) {
|
||||
this.showMissingPermissionsError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ciphers.some((c) => !CipherViewLikeUtils.isArchived(c))) {
|
||||
toastMessage = this.i18nService.t("restoredItems");
|
||||
} else {
|
||||
toastMessage = this.i18nService.t("archivedItemsRestored");
|
||||
}
|
||||
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
@@ -1323,7 +1337,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("restoredItems"),
|
||||
message: toastMessage,
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
204
libs/angular/src/vault/services/custom-nudges-services/README.md
Normal file
204
libs/angular/src/vault/services/custom-nudges-services/README.md
Normal 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],
|
||||
}),
|
||||
```
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f
|
||||
import { AriaDisableDirective } from "../a11y";
|
||||
import { ariaDisableElement } from "../utils";
|
||||
|
||||
export type LinkType = "primary" | "secondary" | "contrast" | "light";
|
||||
export const LinkTypes = [
|
||||
"primary",
|
||||
"secondary",
|
||||
"contrast",
|
||||
"light",
|
||||
"default",
|
||||
"subtle",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
] as const;
|
||||
|
||||
export type LinkType = (typeof LinkTypes)[number];
|
||||
|
||||
const linkStyles: Record<LinkType, string[]> = {
|
||||
primary: [
|
||||
"!tw-text-primary-600",
|
||||
"hover:!tw-text-primary-700",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
],
|
||||
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
|
||||
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
|
||||
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
|
||||
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
|
||||
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
|
||||
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
|
||||
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
|
||||
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
|
||||
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
|
||||
contrast: [
|
||||
"!tw-text-contrast",
|
||||
"hover:!tw-text-contrast",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
"tw-text-fg-contrast",
|
||||
"hover:tw-text-fg-contrast",
|
||||
"focus-visible:before:tw-ring-fg-contrast",
|
||||
],
|
||||
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
|
||||
};
|
||||
|
||||
const commonStyles = [
|
||||
@@ -32,16 +45,18 @@ const commonStyles = [
|
||||
"tw-rounded",
|
||||
"tw-transition",
|
||||
"tw-no-underline",
|
||||
"tw-cursor-pointer",
|
||||
"hover:tw-underline",
|
||||
"hover:tw-decoration-1",
|
||||
"disabled:tw-no-underline",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:!tw-text-secondary-300",
|
||||
"disabled:!tw-text-fg-disabled",
|
||||
"disabled:hover:!tw-text-fg-disabled",
|
||||
"disabled:hover:tw-no-underline",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-underline",
|
||||
"focus-visible:tw-decoration-1",
|
||||
"focus-visible:before:tw-ring-border-focus",
|
||||
|
||||
// Workaround for html button tag not being able to be set to `display: inline`
|
||||
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
|
||||
@@ -63,14 +78,14 @@ const commonStyles = [
|
||||
"focus-visible:tw-z-10",
|
||||
"aria-disabled:tw-no-underline",
|
||||
"aria-disabled:tw-pointer-events-none",
|
||||
"aria-disabled:!tw-text-secondary-300",
|
||||
"aria-disabled:hover:!tw-text-secondary-300",
|
||||
"aria-disabled:!tw-text-fg-disabled",
|
||||
"aria-disabled:hover:!tw-text-fg-disabled",
|
||||
"aria-disabled:hover:tw-no-underline",
|
||||
];
|
||||
|
||||
@Directive()
|
||||
abstract class LinkDirective {
|
||||
readonly linkType = input<LinkType>("primary");
|
||||
readonly linkType = input<LinkType>("default");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components";
|
||||
|
||||
You can use one of the following variants by providing it as the `linkType` input:
|
||||
|
||||
- `primary` - most common, uses brand color
|
||||
- `secondary` - matches the main text color
|
||||
- @deprecated `primary` => use `default` instead
|
||||
- @deprecated `secondary` => use `subtle` instead
|
||||
- `default` - most common, uses brand color
|
||||
- `subtle` - matches the main text color
|
||||
- `contrast` - for high contrast against a dark background (or a light background in dark mode)
|
||||
- `light` - always a light color, even in dark mode
|
||||
- `warning` - used in association with warning callouts/banners
|
||||
- `success` - used in association with success callouts/banners
|
||||
- `danger` - used in association with danger callouts/banners
|
||||
|
||||
## Sizes
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
|
||||
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
|
||||
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
|
||||
import { LinkModule } from "./link.module";
|
||||
|
||||
export default {
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
],
|
||||
argTypes: {
|
||||
linkType: {
|
||||
options: ["primary", "secondary", "contrast"],
|
||||
options: LinkTypes.map((type) => type),
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
@@ -30,48 +30,153 @@ type Story = StoryObj<ButtonLinkDirective>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
linkType: args.linkType,
|
||||
backgroundClass:
|
||||
args.linkType === "contrast"
|
||||
? "tw-bg-bg-contrast"
|
||||
: args.linkType === "light"
|
||||
? "tw-bg-bg-brand"
|
||||
: "tw-bg-transparent",
|
||||
},
|
||||
template: /*html*/ `
|
||||
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
|
||||
<div class="tw-p-2" [class]="backgroundClass">
|
||||
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
linkType: "primary",
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariations: Story = {
|
||||
render: () => ({
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-flex-col tw-gap-6">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="primary" href="#">Primary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="secondary" href="#">Secondary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
|
||||
<a bitLink linkType="contrast" href="#">Contrast</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
|
||||
<a bitLink linkType="light" href="#">Light</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="default" href="#">Default</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="subtle" href="#">Subtle</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="success" href="#">Success</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="warning" href="#">Warning</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="danger" href="#">Danger</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: ["linkType"],
|
||||
hideNoControlsWarning: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractionStates: Story = {
|
||||
render: () => ({
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
|
||||
<div class="tw-flex tw-flex-col tw-gap-6">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="primary" href="#">Primary</a>
|
||||
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
|
||||
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
|
||||
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="secondary" href="#">Secondary</a>
|
||||
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
|
||||
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
|
||||
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
|
||||
<a bitLink linkType="contrast" href="#">Contrast</a>
|
||||
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
|
||||
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
|
||||
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
|
||||
<a bitLink linkType="light" href="#">Light</a>
|
||||
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
|
||||
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
|
||||
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="default" href="#">Default</a>
|
||||
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
|
||||
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
|
||||
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="subtle" href="#">Subtle</a>
|
||||
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
|
||||
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
|
||||
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="success" href="#">Success</a>
|
||||
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
|
||||
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
|
||||
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="warning" href="#">Warning</a>
|
||||
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
|
||||
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
|
||||
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="danger" href="#">Danger</a>
|
||||
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
|
||||
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
|
||||
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: ["linkType"],
|
||||
hideNoControlsWarning: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Buttons: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: {
|
||||
linkType: args.linkType,
|
||||
backgroundClass:
|
||||
args.linkType === "contrast"
|
||||
? "tw-bg-bg-contrast"
|
||||
: args.linkType === "light"
|
||||
? "tw-bg-bg-brand"
|
||||
: "tw-bg-transparent",
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
|
||||
<div class="tw-p-2" [class]="backgroundClass">
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType">Button</button>
|
||||
</div>
|
||||
@@ -100,9 +205,17 @@ export const Buttons: Story = {
|
||||
|
||||
export const Anchors: StoryObj<AnchorLinkDirective> = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: {
|
||||
linkType: args.linkType,
|
||||
backgroundClass:
|
||||
args.linkType === "contrast"
|
||||
? "tw-bg-bg-contrast"
|
||||
: args.linkType === "light"
|
||||
? "tw-bg-bg-brand"
|
||||
: "tw-bg-transparent",
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
|
||||
<div class="tw-p-2" [class]="backgroundClass">
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#">Anchor</a>
|
||||
</div>
|
||||
@@ -138,18 +251,15 @@ export const Inline: Story = {
|
||||
</span>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
linkType: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
export const Inactive: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
|
||||
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
|
||||
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
|
||||
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
|
||||
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
A random password
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -112,13 +112,12 @@ class KitchenSinkDialogComponent {
|
||||
|
||||
<div class="tw-my-6">
|
||||
<h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1>
|
||||
<a bitLink linkType="primary" href="#">This is a link</a>
|
||||
<a bitLink href="#">This is a link</a>
|
||||
<p bitTypography="body1" class="tw-inline">
|
||||
and this is a link button popover trigger:
|
||||
</p>
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -120,5 +120,19 @@ describe("ArchiveCipherUtilitiesService", () => {
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls password reprompt check when unarchiving", async () => {
|
||||
await service.unarchiveCipher(mockCipher);
|
||||
|
||||
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(mockCipher);
|
||||
});
|
||||
|
||||
it("returns early when password reprompt fails on unarchive", async () => {
|
||||
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false);
|
||||
|
||||
await service.unarchiveCipher(mockCipher);
|
||||
|
||||
expect(cipherArchiveService.unarchiveWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user