mirror of
https://github.com/bitwarden/browser
synced 2026-01-28 15:23:53 +00:00
Merge branch 'main' into auth/pm-27086/input-password-use-new-km-data-types
This commit is contained in:
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
run: cargo +nightly udeps --workspace --all-features --all-targets
|
||||
|
||||
- name: Install cargo-deny
|
||||
uses: taiki-e/install-action@2e9d707ef49c9b094d45955b60c7e5c0dfedeb14 # v2.66.5
|
||||
uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7
|
||||
with:
|
||||
tool: cargo-deny@0.18.6
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"scripts": {
|
||||
"build": "npm run build:chrome",
|
||||
"build:bit": "npm run build:bit:chrome",
|
||||
|
||||
@@ -5001,6 +5001,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadAttachmentLabel": {
|
||||
"message": "Download Attachment"
|
||||
},
|
||||
"downloadBitwarden": {
|
||||
"message": "Download Bitwarden"
|
||||
},
|
||||
|
||||
@@ -767,7 +767,6 @@ describe("NotificationBackground", () => {
|
||||
let createWithServerSpy: jest.SpyInstance;
|
||||
let updateWithServerSpy: jest.SpyInstance;
|
||||
let folderExistsSpy: jest.SpyInstance;
|
||||
let cipherEncryptSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
|
||||
@@ -791,7 +790,6 @@ describe("NotificationBackground", () => {
|
||||
createWithServerSpy = jest.spyOn(cipherService, "createWithServer");
|
||||
updateWithServerSpy = jest.spyOn(cipherService, "updateWithServer");
|
||||
folderExistsSpy = jest.spyOn(notificationBackground as any, "folderExists");
|
||||
cipherEncryptSpy = jest.spyOn(cipherService, "encrypt");
|
||||
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
});
|
||||
@@ -1190,13 +1188,7 @@ describe("NotificationBackground", () => {
|
||||
folderExistsSpy.mockResolvedValueOnce(false);
|
||||
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
|
||||
editItemSpy.mockResolvedValueOnce(undefined);
|
||||
cipherEncryptSpy.mockResolvedValueOnce({
|
||||
cipher: {
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
},
|
||||
encryptedFor: userId,
|
||||
});
|
||||
createWithServerSpy.mockResolvedValueOnce(cipherView);
|
||||
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
@@ -1205,7 +1197,6 @@ describe("NotificationBackground", () => {
|
||||
queueMessage,
|
||||
null,
|
||||
);
|
||||
expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId");
|
||||
expect(createWithServerSpy).toHaveBeenCalled();
|
||||
expect(tabSendMessageDataSpy).toHaveBeenCalledWith(
|
||||
sender.tab,
|
||||
@@ -1241,13 +1232,6 @@ describe("NotificationBackground", () => {
|
||||
folderExistsSpy.mockResolvedValueOnce(true);
|
||||
convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView);
|
||||
editItemSpy.mockResolvedValueOnce(undefined);
|
||||
cipherEncryptSpy.mockResolvedValueOnce({
|
||||
cipher: {
|
||||
...cipherView,
|
||||
id: "testId",
|
||||
},
|
||||
encryptedFor: userId,
|
||||
});
|
||||
const errorMessage = "fetch error";
|
||||
createWithServerSpy.mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
@@ -1256,7 +1240,6 @@ describe("NotificationBackground", () => {
|
||||
sendMockExtensionMessage(message, sender);
|
||||
await flushPromises();
|
||||
|
||||
expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId");
|
||||
expect(createWithServerSpy).toThrow(errorMessage);
|
||||
expect(tabSendMessageSpy).not.toHaveBeenCalledWith(sender.tab, {
|
||||
command: "addedCipher",
|
||||
|
||||
@@ -866,13 +866,11 @@ export default class NotificationBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
const encrypted = await this.cipherService.encrypt(newCipher, activeUserId);
|
||||
const { cipher } = encrypted;
|
||||
try {
|
||||
await this.cipherService.createWithServer(encrypted);
|
||||
const resultCipher = await this.cipherService.createWithServer(newCipher, activeUserId);
|
||||
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
|
||||
itemName: newCipher?.name && String(newCipher?.name),
|
||||
cipherId: cipher?.id && String(cipher?.id),
|
||||
cipherId: resultCipher?.id && String(resultCipher?.id),
|
||||
});
|
||||
await BrowserApi.tabSendMessage(tab, { command: "addedCipher" });
|
||||
} catch (error) {
|
||||
@@ -910,7 +908,6 @@ export default class NotificationBackground {
|
||||
await BrowserApi.tabSendMessage(tab, { command: "editedCipher" });
|
||||
return;
|
||||
}
|
||||
const cipher = await this.cipherService.encrypt(cipherView, userId);
|
||||
|
||||
try {
|
||||
if (!cipherView.edit) {
|
||||
@@ -939,7 +936,7 @@ export default class NotificationBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cipherService.updateWithServer(cipher);
|
||||
await this.cipherService.updateWithServer(cipherView, userId);
|
||||
|
||||
await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", {
|
||||
itemName: cipherView?.name && String(cipherView?.name),
|
||||
|
||||
@@ -444,10 +444,9 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
this.buildCipher(name, username);
|
||||
const encrypted = await this.cipherService.encrypt(this.cipher, activeUserId);
|
||||
try {
|
||||
await this.cipherService.createWithServer(encrypted);
|
||||
this.cipher.id = encrypted.cipher.id;
|
||||
const result = await this.cipherService.createWithServer(this.cipher, activeUserId);
|
||||
this.cipher.id = result.id;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -211,6 +212,7 @@ import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||
@@ -367,6 +369,7 @@ export default class MainBackground {
|
||||
apiService: ApiServiceAbstraction;
|
||||
hibpApiService: HibpApiService;
|
||||
environmentService: BrowserEnvironmentService;
|
||||
cipherSdkService: CipherSdkService;
|
||||
cipherService: CipherServiceAbstraction;
|
||||
folderService: InternalFolderServiceAbstraction;
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
|
||||
@@ -973,6 +976,8 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
|
||||
|
||||
this.cipherService = new CipherService(
|
||||
this.keyService,
|
||||
this.domainSettingsService,
|
||||
@@ -988,6 +993,7 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
this.cipherSdkService,
|
||||
);
|
||||
this.folderService = new FolderService(
|
||||
this.keyService,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type PhishingResource = {
|
||||
name?: string;
|
||||
remoteUrl: string;
|
||||
/** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */
|
||||
fallbackUrl: string;
|
||||
checksumUrl: string;
|
||||
todayUrl: string;
|
||||
/** Matcher used to decide whether a given URL matches an entry from this resource */
|
||||
@@ -19,6 +21,8 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
|
||||
{
|
||||
name: "Phishing.Database Domains",
|
||||
remoteUrl: "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
|
||||
fallbackUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-ACTIVE.txt",
|
||||
checksumUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5",
|
||||
todayUrl:
|
||||
@@ -46,6 +50,8 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
|
||||
{
|
||||
name: "Phishing.Database Links",
|
||||
remoteUrl: "https://phish.co.za/latest/phishing-links-ACTIVE.txt",
|
||||
fallbackUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-ACTIVE.txt",
|
||||
checksumUrl:
|
||||
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
|
||||
todayUrl:
|
||||
@@ -71,10 +77,10 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if URL starts with entry (prefix match for subpaths/query/hash)
|
||||
// e.g., entry "site.com/phish" matches "site.com/phish/subpage" or "site.com/phish?id=1"
|
||||
// Check if URL starts with entry (prefix match for query/hash only, NOT subpaths)
|
||||
// e.g., entry "site.com/phish" matches "site.com/phish?id=1" or "site.com/phish#section"
|
||||
// but NOT "site.com/phish/subpage" (different endpoint)
|
||||
if (
|
||||
urlNoProto.startsWith(entryNoProto + "/") ||
|
||||
urlNoProto.startsWith(entryNoProto + "?") ||
|
||||
urlNoProto.startsWith(entryNoProto + "#")
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -9,66 +10,8 @@ import {
|
||||
import { FakeGlobalStateProvider } from "@bitwarden/common/spec";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import {
|
||||
PhishingDataService,
|
||||
PHISHING_DOMAINS_META_KEY,
|
||||
PHISHING_DOMAINS_BLOB_KEY,
|
||||
PhishingDataMeta,
|
||||
PhishingDataBlob,
|
||||
} from "./phishing-data.service";
|
||||
|
||||
const flushPromises = () =>
|
||||
new Promise((resolve) => jest.requireActual("timers").setImmediate(resolve));
|
||||
|
||||
// [FIXME] Move mocking and compression helpers to a shared test utils library
|
||||
// to separate from phishing data service tests.
|
||||
export const setupPhishingMocks = (mockedResult: string | ArrayBuffer = "mocked-data") => {
|
||||
// Store original globals
|
||||
const originals = {
|
||||
Response: global.Response,
|
||||
CompressionStream: global.CompressionStream,
|
||||
DecompressionStream: global.DecompressionStream,
|
||||
Blob: global.Blob,
|
||||
atob: global.atob,
|
||||
btoa: global.btoa,
|
||||
};
|
||||
|
||||
// Mock missing or browser-only globals
|
||||
global.atob = (str) => Buffer.from(str, "base64").toString("binary");
|
||||
global.btoa = (str) => Buffer.from(str, "binary").toString("base64");
|
||||
|
||||
(global as any).CompressionStream = class {};
|
||||
(global as any).DecompressionStream = class {};
|
||||
|
||||
global.Blob = class {
|
||||
constructor(public parts: any[]) {}
|
||||
stream() {
|
||||
return { pipeThrough: () => ({}) };
|
||||
}
|
||||
} as any;
|
||||
|
||||
global.Response = class {
|
||||
body = { pipeThrough: () => ({}) };
|
||||
// Return string for decompression
|
||||
text() {
|
||||
return Promise.resolve(typeof mockedResult === "string" ? mockedResult : "");
|
||||
}
|
||||
// Return ArrayBuffer for compression
|
||||
arrayBuffer() {
|
||||
if (typeof mockedResult === "string") {
|
||||
const bytes = new TextEncoder().encode(mockedResult);
|
||||
return Promise.resolve(bytes.buffer);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockedResult);
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
Object.assign(global, originals);
|
||||
};
|
||||
};
|
||||
import { PHISHING_DOMAINS_META_KEY, PhishingDataService } from "./phishing-data.service";
|
||||
import type { PhishingIndexedDbService } from "./phishing-indexeddb.service";
|
||||
|
||||
describe("PhishingDataService", () => {
|
||||
let service: PhishingDataService;
|
||||
@@ -76,33 +19,30 @@ describe("PhishingDataService", () => {
|
||||
let taskSchedulerService: TaskSchedulerService;
|
||||
let logService: MockProxy<LogService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let mockIndexedDbService: MockProxy<PhishingIndexedDbService>;
|
||||
const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
const setMockMeta = (state: PhishingDataMeta) => {
|
||||
fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_META_KEY).stateSubject.next(state);
|
||||
return state;
|
||||
};
|
||||
const setMockBlob = (state: PhishingDataBlob) => {
|
||||
fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
let fetchChecksumSpy: jest.SpyInstance;
|
||||
let fetchAndCompressSpy: jest.SpyInstance;
|
||||
|
||||
const mockMeta: PhishingDataMeta = {
|
||||
checksum: "abc",
|
||||
timestamp: Date.now(),
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
const mockBlob = "http://phish.com\nhttps://badguy.net";
|
||||
const mockCompressedBlob =
|
||||
"H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA=";
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock Request global if not available
|
||||
if (typeof Request === "undefined") {
|
||||
(global as any).Request = class {
|
||||
constructor(public url: string) {}
|
||||
};
|
||||
}
|
||||
|
||||
apiService = mock<ApiService>();
|
||||
logService = mock<LogService>();
|
||||
mockIndexedDbService = mock<PhishingIndexedDbService>();
|
||||
|
||||
// Set default mock behaviors
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue([]);
|
||||
mockIndexedDbService.saveUrls.mockResolvedValue(undefined);
|
||||
mockIndexedDbService.addUrls.mockResolvedValue(undefined);
|
||||
mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined);
|
||||
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0");
|
||||
@@ -116,217 +56,315 @@ describe("PhishingDataService", () => {
|
||||
logService,
|
||||
platformUtilsService,
|
||||
);
|
||||
fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum");
|
||||
fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress");
|
||||
|
||||
// Replace the IndexedDB service with our mock
|
||||
service["indexedDbService"] = mockIndexedDbService;
|
||||
|
||||
fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum");
|
||||
fetchChecksumSpy.mockResolvedValue("new-checksum");
|
||||
fetchAndCompressSpy.mockResolvedValue("compressed-blob");
|
||||
});
|
||||
|
||||
describe("initialization", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob);
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob);
|
||||
it("should initialize with IndexedDB service", () => {
|
||||
expect(service["indexedDbService"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should perform background update", async () => {
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.x");
|
||||
jest
|
||||
.spyOn(service as any, "getNextWebAddresses")
|
||||
.mockResolvedValue({ meta: mockMeta, blob: mockBlob });
|
||||
|
||||
setMockBlob(mockBlob);
|
||||
setMockMeta(mockMeta);
|
||||
|
||||
const sub = service.update$.subscribe();
|
||||
await flushPromises();
|
||||
|
||||
const url = new URL("http://phish.com");
|
||||
const QAurl = new URL("http://phishing.testcategory.com");
|
||||
it("should detect QA test addresses - http protocol", async () => {
|
||||
const url = new URL("http://phishing.testcategory.com");
|
||||
expect(await service.isPhishingWebAddress(url)).toBe(true);
|
||||
expect(await service.isPhishingWebAddress(QAurl)).toBe(true);
|
||||
// IndexedDB should not be called for test addresses
|
||||
expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
it("should detect QA test addresses - https protocol", async () => {
|
||||
const url = new URL("https://phishing.testcategory.com");
|
||||
expect(await service.isPhishingWebAddress(url)).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should detect QA test addresses - specific subpath /block", async () => {
|
||||
const url = new URL("https://phishing.testcategory.com/block");
|
||||
expect(await service.isPhishingWebAddress(url)).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT detect QA test addresses - different subpath", async () => {
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue([]);
|
||||
|
||||
const url = new URL("https://phishing.testcategory.com/other");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
// This should NOT be detected as a test address since only /block subpath is hardcoded
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect QA test addresses - root path with trailing slash", async () => {
|
||||
const url = new URL("https://phishing.testcategory.com/");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
// This SHOULD be detected since URLs are normalized (trailing slash added to root URLs)
|
||||
expect(result).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPhishingWebAddress", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob);
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob);
|
||||
});
|
||||
it("should detect a phishing web address using quick hasUrl lookup", async () => {
|
||||
// Mock hasUrl to return true for direct hostname match
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(true);
|
||||
|
||||
it("should detect a phishing web address", async () => {
|
||||
service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]);
|
||||
|
||||
const url = new URL("http://phish.com");
|
||||
const url = new URL("http://phish.com/testing-param");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param");
|
||||
// Should not fall back to custom matcher when hasUrl returns true
|
||||
expect(mockIndexedDbService.loadAllUrls).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fall back to custom matcher when hasUrl returns false", async () => {
|
||||
// Mock hasUrl to return false (no direct href match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return phishing URLs for custom matcher
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/path"]);
|
||||
|
||||
const url = new URL("http://phish.com/path");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not detect a safe web address", async () => {
|
||||
service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]);
|
||||
// Mock hasUrl to return false
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return phishing URLs that don't match
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com", "http://badguy.net"]);
|
||||
|
||||
const url = new URL("http://safe.com");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should match against root web address", async () => {
|
||||
service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]);
|
||||
const url = new URL("http://phish.com/about");
|
||||
it("should not match against root web address with subpaths using custom matcher", async () => {
|
||||
// Mock hasUrl to return false (no direct href match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return entry that matches with subpath
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login"]);
|
||||
|
||||
const url = new URL("http://phish.com/login/page");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not error on empty state", async () => {
|
||||
service["_webAddressesSet"] = null;
|
||||
it("should not match against root web address with different subpaths using custom matcher", async () => {
|
||||
// Mock hasUrl to return false (no direct hostname match)
|
||||
mockIndexedDbService.hasUrl.mockResolvedValue(false);
|
||||
// Mock loadAllUrls to return entry that matches with subpath
|
||||
mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login/page1"]);
|
||||
|
||||
const url = new URL("http://phish.com/login/page2");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2");
|
||||
expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle IndexedDB errors gracefully", async () => {
|
||||
// Mock hasUrl to throw error
|
||||
mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error"));
|
||||
// Mock loadAllUrls to also throw error
|
||||
mockIndexedDbService.loadAllUrls.mockRejectedValue(new Error("IndexedDB error"));
|
||||
|
||||
const url = new URL("http://phish.com/about");
|
||||
const result = await service.isPhishingWebAddress(url);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingDataService] IndexedDB lookup via hasUrl failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingDataService] Error running custom matcher",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextWebAddresses", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob);
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob);
|
||||
describe("data updates", () => {
|
||||
it("should update full dataset via stream", async () => {
|
||||
// Mock full dataset update
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {} as ReadableStream,
|
||||
} as Response;
|
||||
apiService.nativeFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await firstValueFrom(service["_updateFullDataSet"]());
|
||||
|
||||
expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refetches all web addresses if applicationVersion has changed", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: Date.now() - 60000,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
it("should update daily dataset via addUrls", async () => {
|
||||
// Mock daily update
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
text: jest.fn().mockResolvedValue("newphish.com\nanotherbad.net"),
|
||||
} as unknown as Response;
|
||||
apiService.nativeFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
await firstValueFrom(service["_updateDailyDataSet"]());
|
||||
|
||||
expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newphish.com", "anotherbad.net"]);
|
||||
});
|
||||
|
||||
it("should get updated meta information", async () => {
|
||||
fetchChecksumSpy.mockResolvedValue("new-checksum");
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0");
|
||||
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
const meta = await firstValueFrom(service["_getUpdatedMeta"]());
|
||||
|
||||
expect(result!.blob).toBe("compressed-blob");
|
||||
expect(result!.meta!.checksum).toBe("new");
|
||||
expect(result!.meta!.applicationVersion).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("returns null when checksum matches and cache not expired", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: Date.now(),
|
||||
checksum: "abc",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("abc");
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("patches daily domains when cache is expired and checksum unchanged", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: 0,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
const dailyLines = ["b.com", "c.com"];
|
||||
fetchChecksumSpy.mockResolvedValue("old");
|
||||
jest.spyOn(service as any, "fetchText").mockResolvedValue(dailyLines);
|
||||
|
||||
setMockBlob(mockBlob);
|
||||
|
||||
const expectedBlob =
|
||||
"H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA=";
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
|
||||
expect(result!.blob).toBe(expectedBlob);
|
||||
expect(result!.meta!.checksum).toBe("old");
|
||||
});
|
||||
|
||||
it("fetches all domains when checksum has changed", async () => {
|
||||
const prev: PhishingDataMeta = {
|
||||
timestamp: 0,
|
||||
checksum: "old",
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
fetchChecksumSpy.mockResolvedValue("new");
|
||||
fetchAndCompressSpy.mockResolvedValue("new-blob");
|
||||
const result = await service.getNextWebAddresses(prev);
|
||||
expect(result!.blob).toBe("new-blob");
|
||||
expect(result!.meta!.checksum).toBe("new");
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.checksum).toBe("new-checksum");
|
||||
expect(meta.applicationVersion).toBe("2.0.0");
|
||||
expect(meta.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compression helpers", () => {
|
||||
let restore: () => void;
|
||||
describe("phishing meta data updates", () => {
|
||||
it("should not update metadata when no data updates occur", async () => {
|
||||
// Set up existing metadata
|
||||
const existingMeta = {
|
||||
checksum: "existing-checksum",
|
||||
timestamp: Date.now() - 1000, // 1 second ago (not expired)
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta);
|
||||
|
||||
beforeEach(async () => {
|
||||
restore = setupPhishingMocks("abc");
|
||||
// Mock conditions where no update is needed
|
||||
fetchChecksumSpy.mockResolvedValue("existing-checksum"); // Same checksum
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {} as ReadableStream,
|
||||
} as Response;
|
||||
apiService.nativeFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
// Trigger background update
|
||||
const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta));
|
||||
|
||||
// Verify metadata was NOT updated (same reference returned)
|
||||
expect(result).toEqual(existingMeta);
|
||||
expect(result?.timestamp).toBe(existingMeta.timestamp);
|
||||
|
||||
// Verify no data updates were performed
|
||||
expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (restore) {
|
||||
restore();
|
||||
}
|
||||
delete (Uint8Array as any).fromBase64;
|
||||
jest.restoreAllMocks();
|
||||
it("should update metadata when full dataset update occurs due to checksum change", async () => {
|
||||
// Set up existing metadata
|
||||
const existingMeta = {
|
||||
checksum: "old-checksum",
|
||||
timestamp: Date.now() - 1000,
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta);
|
||||
|
||||
// Mock conditions for full update
|
||||
fetchChecksumSpy.mockResolvedValue("new-checksum"); // Different checksum
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0");
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {} as ReadableStream,
|
||||
} as Response;
|
||||
apiService.nativeFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
// Trigger background update
|
||||
const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta));
|
||||
|
||||
// Verify metadata WAS updated with new values
|
||||
expect(result?.checksum).toBe("new-checksum");
|
||||
expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp);
|
||||
|
||||
// Verify full update was performed
|
||||
expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); // Daily should not run
|
||||
});
|
||||
|
||||
describe("_compressString", () => {
|
||||
it("compresses a string to base64", async () => {
|
||||
const out = await service["_compressString"]("abc");
|
||||
expect(out).toBe("YWJj"); // base64 for 'abc'
|
||||
});
|
||||
it("should update metadata when full dataset update occurs due to version change", async () => {
|
||||
// Set up existing metadata
|
||||
const existingMeta = {
|
||||
checksum: "same-checksum",
|
||||
timestamp: Date.now() - 1000,
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta);
|
||||
|
||||
it("compresses using fallback on older browsers", async () => {
|
||||
const input = "abc";
|
||||
const expected = btoa(encodeURIComponent(input));
|
||||
const out = await service["_compressString"](input);
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
// Mock conditions for full update
|
||||
fetchChecksumSpy.mockResolvedValue("same-checksum");
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); // Different version
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {} as ReadableStream,
|
||||
} as Response;
|
||||
apiService.nativeFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
it("compresses using btoa on error", async () => {
|
||||
const input = "abc";
|
||||
const expected = btoa(encodeURIComponent(input));
|
||||
const out = await service["_compressString"](input);
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
// Trigger background update
|
||||
const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta));
|
||||
|
||||
// Verify metadata WAS updated
|
||||
expect(result?.applicationVersion).toBe("2.0.0");
|
||||
expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp);
|
||||
|
||||
// Verify full update was performed
|
||||
expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled();
|
||||
});
|
||||
describe("_decompressString", () => {
|
||||
it("decompresses a string from base64", async () => {
|
||||
const base64 = btoa("ignored");
|
||||
const out = await service["_decompressString"](base64);
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
|
||||
it("decompresses using fallback on older browsers", async () => {
|
||||
// Provide a fromBase64 implementation
|
||||
(Uint8Array as any).fromBase64 = (b64: string) => new Uint8Array([100, 101, 102]);
|
||||
it("should update metadata when daily update occurs due to cache expiration", async () => {
|
||||
// Set up existing metadata (expired cache)
|
||||
const existingMeta = {
|
||||
checksum: "same-checksum",
|
||||
timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago (expired)
|
||||
applicationVersion: "1.0.0",
|
||||
};
|
||||
await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta);
|
||||
|
||||
const out = await service["_decompressString"]("ignored");
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
// Mock conditions for daily update only
|
||||
fetchChecksumSpy.mockResolvedValue("same-checksum"); // Same checksum (no full update)
|
||||
platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version
|
||||
const mockFullResponse = {
|
||||
ok: true,
|
||||
body: {} as ReadableStream,
|
||||
} as Response;
|
||||
const mockDailyResponse = {
|
||||
ok: true,
|
||||
text: jest.fn().mockResolvedValue("newdomain.com"),
|
||||
} as unknown as Response;
|
||||
apiService.nativeFetch
|
||||
.mockResolvedValueOnce(mockFullResponse)
|
||||
.mockResolvedValueOnce(mockDailyResponse);
|
||||
|
||||
it("decompresses using atob on error", async () => {
|
||||
const base64 = btoa(encodeURIComponent("abc"));
|
||||
const out = await service["_decompressString"](base64);
|
||||
expect(out).toBe("abc");
|
||||
});
|
||||
});
|
||||
});
|
||||
// Trigger background update
|
||||
const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta));
|
||||
|
||||
describe("_loadBlobToMemory", () => {
|
||||
it("loads blob into memory set", async () => {
|
||||
const prevBlob = "ignored-base64";
|
||||
fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob);
|
||||
// Verify metadata WAS updated
|
||||
expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp);
|
||||
expect(result?.checksum).toBe("same-checksum");
|
||||
|
||||
jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net");
|
||||
|
||||
// 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);
|
||||
expect(set.has("badguy.net")).toBe(true);
|
||||
// Verify only daily update was performed
|
||||
expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled();
|
||||
expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newdomain.com"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
defer,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
first,
|
||||
firstValueFrom,
|
||||
forkJoin,
|
||||
from,
|
||||
iif,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
retry,
|
||||
share,
|
||||
takeUntil,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
map,
|
||||
throwError,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags";
|
||||
@@ -23,6 +31,8 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi
|
||||
|
||||
import { getPhishingResources, PhishingResourceType } from "../phishing-resources";
|
||||
|
||||
import { PhishingIndexedDbService } from "./phishing-indexeddb.service";
|
||||
|
||||
/**
|
||||
* Metadata about the phishing data set
|
||||
*/
|
||||
@@ -73,19 +83,16 @@ export class PhishingDataService {
|
||||
// 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 _testWebAddresses = this.getTestWebAddresses();
|
||||
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>();
|
||||
private indexedDbService: PhishingIndexedDbService;
|
||||
|
||||
// How often are new web addresses added to the remote?
|
||||
readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
private _backgroundUpdateTrigger$ = new Subject<PhishingDataMeta | null>();
|
||||
|
||||
private _triggerUpdate$ = new Subject<void>();
|
||||
update$ = this._triggerUpdate$.pipe(
|
||||
startWith(undefined), // Always emit once
|
||||
@@ -93,12 +100,8 @@ 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);
|
||||
// Perform any updates in the background
|
||||
this._backgroundUpdateTrigger$.next(metaState);
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error("[PhishingDataService] Background update failed to start.", err);
|
||||
@@ -106,7 +109,6 @@ export class PhishingDataService {
|
||||
}),
|
||||
),
|
||||
),
|
||||
// Stop emitting when dispose() is called
|
||||
takeUntil(this._destroy$),
|
||||
share(),
|
||||
);
|
||||
@@ -120,6 +122,7 @@ export class PhishingDataService {
|
||||
private resourceType: PhishingResourceType = PhishingResourceType.Links,
|
||||
) {
|
||||
this.logService.debug("[PhishingDataService] Initializing service...");
|
||||
this.indexedDbService = new PhishingIndexedDbService(this.logService);
|
||||
this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => {
|
||||
this._triggerUpdate$.next();
|
||||
});
|
||||
@@ -127,18 +130,20 @@ export class PhishingDataService {
|
||||
ScheduledTaskNames.phishingDomainUpdate,
|
||||
this.UPDATE_INTERVAL_DURATION,
|
||||
);
|
||||
this._setupLoadPipeline();
|
||||
this._backgroundUpdateTrigger$
|
||||
.pipe(
|
||||
exhaustMap((currentMeta) => {
|
||||
return this._backgroundUpdate(currentMeta);
|
||||
}),
|
||||
takeUntil(this._destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,105 +153,65 @@ export class PhishingDataService {
|
||||
* @returns True if the URL is a known phishing web address, false otherwise
|
||||
*/
|
||||
async isPhishingWebAddress(url: URL): Promise<boolean> {
|
||||
if (!this._webAddressesSet) {
|
||||
this.logService.debug("[PhishingDataService] Set not loaded; skipping check");
|
||||
return false;
|
||||
// Quick check for QA/dev test addresses
|
||||
if (this._testWebAddresses.includes(url.href)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const set = this._webAddressesSet!;
|
||||
const resource = getPhishingResources(this.resourceType);
|
||||
|
||||
// Custom matcher per resource
|
||||
if (resource && resource?.match) {
|
||||
for (const entry of set) {
|
||||
if (resource.match(url, entry)) {
|
||||
return true;
|
||||
try {
|
||||
// Quick lookup: check direct presence of href in IndexedDB
|
||||
const hasUrl = await this.indexedDbService.hasUrl(url.href);
|
||||
if (hasUrl) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err);
|
||||
}
|
||||
|
||||
// If a custom matcher is provided, iterate stored entries and apply the matcher.
|
||||
if (resource && resource.match) {
|
||||
try {
|
||||
const entries = await this.indexedDbService.loadAllUrls();
|
||||
for (const entry of entries) {
|
||||
if (resource.match(url, entry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Error running custom matcher", err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default set-based lookup
|
||||
return set.has(url.hostname);
|
||||
}
|
||||
|
||||
async getNextWebAddresses(
|
||||
previous: PhishingDataMeta | null,
|
||||
): Promise<Partial<PhishingData> | null> {
|
||||
const prevMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "" };
|
||||
const now = Date.now();
|
||||
|
||||
// Updates to check
|
||||
const applicationVersion = await this.platformUtilsService.getApplicationVersion();
|
||||
const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType);
|
||||
|
||||
// Logic checks
|
||||
const appVersionChanged = applicationVersion !== prevMeta.applicationVersion;
|
||||
const masterChecksumChanged = remoteChecksum !== prevMeta.checksum;
|
||||
|
||||
// Check for full updated
|
||||
if (masterChecksumChanged || appVersionChanged) {
|
||||
this.logService.info("[PhishingDataService] Checksum or version changed; Fetching ALL.");
|
||||
const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl;
|
||||
const blob = await this.fetchAndCompress(remoteUrl);
|
||||
return {
|
||||
blob,
|
||||
meta: { checksum: remoteChecksum, timestamp: now, applicationVersion },
|
||||
};
|
||||
}
|
||||
|
||||
// Check for daily file
|
||||
const isCacheExpired = now - prevMeta.timestamp > this.UPDATE_INTERVAL_DURATION;
|
||||
|
||||
if (isCacheExpired) {
|
||||
this.logService.info("[PhishingDataService] Daily cache expired; Fetching TODAY's");
|
||||
const url = getPhishingResources(this.resourceType)!.todayUrl;
|
||||
const newLines = await this.fetchText(url);
|
||||
const prevBlob = (await firstValueFrom(this._phishingBlobState.state$)) ?? "";
|
||||
const oldText = prevBlob ? await this._decompressString(prevBlob) : "";
|
||||
|
||||
// Join the new lines to the existing list
|
||||
const combined = (oldText ? oldText + "\n" : "") + newLines.join("\n");
|
||||
|
||||
return {
|
||||
blob: await this._compressString(combined),
|
||||
meta: {
|
||||
checksum: remoteChecksum,
|
||||
timestamp: now, // Reset the timestamp
|
||||
applicationVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// [FIXME] Pull fetches into api service
|
||||
private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) {
|
||||
const checksumUrl = getPhishingResources(type)!.checksumUrl;
|
||||
const response = await this.apiService.nativeFetch(new Request(checksumUrl));
|
||||
if (!response.ok) {
|
||||
throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
private async fetchAndCompress(url: string): Promise<string> {
|
||||
const response = await this.apiService.nativeFetch(new Request(url));
|
||||
if (!response.ok) {
|
||||
throw new Error("Fetch failed");
|
||||
}
|
||||
this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`);
|
||||
|
||||
const downloadStream = response.body!;
|
||||
// Pipe through CompressionStream while it's downloading
|
||||
const compressedStream = downloadStream.pipeThrough(new CompressionStream("gzip"));
|
||||
// Convert to ArrayBuffer
|
||||
const buffer = await new Response(compressedStream).arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
try {
|
||||
const response = await this.apiService.nativeFetch(new Request(checksumUrl));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`[PhishingDataService] Failed to fetch checksum: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return as Base64 for storage
|
||||
return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes);
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Checksum fetch failed from ${checksumUrl}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchText(url: string) {
|
||||
// [FIXME] Pull fetches into api service
|
||||
private async fetchToday(url: string) {
|
||||
const response = await this.apiService.nativeFetch(new Request(url));
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -258,171 +223,196 @@ export class PhishingDataService {
|
||||
|
||||
private getTestWebAddresses() {
|
||||
const flag = devFlagEnabled("testPhishingUrls");
|
||||
// Normalize URLs by converting to URL object and back to ensure consistent format (e.g., trailing slashes)
|
||||
const testWebAddresses: string[] = [
|
||||
new URL("http://phishing.testcategory.com").href,
|
||||
new URL("https://phishing.testcategory.com").href,
|
||||
new URL("https://phishing.testcategory.com/block").href,
|
||||
];
|
||||
if (!flag) {
|
||||
return [];
|
||||
return testWebAddresses;
|
||||
}
|
||||
|
||||
const webAddresses = devFlagValue("testPhishingUrls") as unknown[];
|
||||
if (webAddresses && webAddresses instanceof Array) {
|
||||
this.logService.debug(
|
||||
"[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:",
|
||||
"[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:",
|
||||
webAddresses,
|
||||
);
|
||||
return webAddresses as string[];
|
||||
// Normalize dev flag URLs as well, filtering out invalid ones
|
||||
const normalizedDevAddresses = (webAddresses as string[])
|
||||
.filter((addr) => {
|
||||
try {
|
||||
new URL(addr);
|
||||
return true;
|
||||
} catch {
|
||||
this.logService.warning(
|
||||
`[PhishingDataService] Invalid test URL in dev flag, skipping: ${addr}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((addr) => new URL(addr).href);
|
||||
return testWebAddresses.concat(normalizedDevAddresses);
|
||||
}
|
||||
return [];
|
||||
return testWebAddresses;
|
||||
}
|
||||
|
||||
// Runs the update flow in the background and retries up to 3 times on failure
|
||||
private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise<void> {
|
||||
this.logService.info(`[PhishingDataService] Update web addresses triggered...`);
|
||||
const phishingMeta: PhishingDataMeta = previous ?? {
|
||||
timestamp: 0,
|
||||
checksum: "",
|
||||
applicationVersion: "",
|
||||
};
|
||||
// Start time for logging performance of update
|
||||
const startTime = Date.now();
|
||||
const maxAttempts = 3;
|
||||
const delayMs = 5 * 60 * 1000; // 5 minutes
|
||||
private _getUpdatedMeta(): Observable<PhishingDataMeta> {
|
||||
return defer(() => {
|
||||
const now = Date.now();
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const next = await this.getNextWebAddresses(phishingMeta);
|
||||
if (!next) {
|
||||
return; // No update needed
|
||||
}
|
||||
return forkJoin({
|
||||
applicationVersion: from(this.platformUtilsService.getApplicationVersion()),
|
||||
remoteChecksum: from(this.fetchPhishingChecksum(this.resourceType)),
|
||||
}).pipe(
|
||||
map(({ applicationVersion, remoteChecksum }) => {
|
||||
return {
|
||||
checksum: remoteChecksum,
|
||||
timestamp: now,
|
||||
applicationVersion,
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (next.meta) {
|
||||
await this._phishingMetaState.update(() => next!.meta!);
|
||||
}
|
||||
if (next.blob) {
|
||||
await this._phishingBlobState.update(() => next!.blob!);
|
||||
this._loadBlobToMemory();
|
||||
}
|
||||
// Streams the full phishing data set and saves it to IndexedDB
|
||||
private _updateFullDataSet() {
|
||||
const resource = getPhishingResources(this.resourceType);
|
||||
|
||||
// Performance logging
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logService.info(`[PhishingDataService] Phishing data cache updated in ${elapsed}ms`);
|
||||
} catch (err) {
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`,
|
||||
err,
|
||||
);
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise((res) => setTimeout(res, delayMs));
|
||||
} else {
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`,
|
||||
err,
|
||||
if (!resource?.remoteUrl) {
|
||||
return throwError(() => new Error("Invalid resource URL"));
|
||||
}
|
||||
|
||||
this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`);
|
||||
return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe(
|
||||
switchMap((response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
return throwError(
|
||||
() =>
|
||||
new Error(
|
||||
`[PhishingDataService] Full fetch failed: ${response.status}, ${response.statusText}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return from(this.indexedDbService.saveUrlsFromStream(response.body));
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Full dataset update failed using primary source ${err}`,
|
||||
);
|
||||
this.logService.warning(
|
||||
`[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`,
|
||||
);
|
||||
// Try fallback URL
|
||||
return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe(
|
||||
switchMap((fallbackResponse) => {
|
||||
if (!fallbackResponse.ok || !fallbackResponse.body) {
|
||||
return throwError(
|
||||
() =>
|
||||
new Error(
|
||||
`[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body));
|
||||
}),
|
||||
catchError((fallbackError: unknown) => {
|
||||
this.logService.error(`[PhishingDataService] Fallback source failed`);
|
||||
return throwError(() => fallbackError);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 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`,
|
||||
);
|
||||
}),
|
||||
private _updateDailyDataSet() {
|
||||
this.logService.info("[PhishingDataService] Starting DAILY update...");
|
||||
|
||||
const todayUrl = getPhishingResources(this.resourceType)?.todayUrl;
|
||||
if (!todayUrl) {
|
||||
return throwError(() => new Error("Today URL missing"));
|
||||
}
|
||||
|
||||
return from(this.fetchToday(todayUrl)).pipe(
|
||||
switchMap((lines) => from(this.indexedDbService.addUrls(lines))),
|
||||
);
|
||||
}
|
||||
|
||||
private _backgroundUpdate(
|
||||
previous: PhishingDataMeta | null,
|
||||
): Observable<PhishingDataMeta | null> {
|
||||
// Use defer to restart timer if retry is activated
|
||||
return defer(() => {
|
||||
const startTime = Date.now();
|
||||
this.logService.info(`[PhishingDataService] Update triggered...`);
|
||||
|
||||
// Get updated meta info
|
||||
return this._getUpdatedMeta().pipe(
|
||||
// Update full data set if application version or checksum changed
|
||||
concatMap((newMeta) =>
|
||||
iif(
|
||||
() => {
|
||||
const appVersionChanged = newMeta.applicationVersion !== previous?.applicationVersion;
|
||||
const checksumChanged = newMeta.checksum !== previous?.checksum;
|
||||
|
||||
this.logService.info(
|
||||
`[PhishingDataService] Checking if full update is needed: appVersionChanged=${appVersionChanged}, checksumChanged=${checksumChanged}`,
|
||||
);
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error("[PhishingDataService] Failed to load blob into memory", err);
|
||||
return of(undefined);
|
||||
}),
|
||||
return appVersionChanged || checksumChanged;
|
||||
},
|
||||
this._updateFullDataSet().pipe(map(() => ({ meta: newMeta, updated: true }))),
|
||||
of({ meta: newMeta, updated: false }),
|
||||
),
|
||||
),
|
||||
catchError((err: unknown) => {
|
||||
this.logService.error("[PhishingDataService] Load pipeline failed", err);
|
||||
return of(undefined);
|
||||
// Update daily data set if last update was more than UPDATE_INTERVAL_DURATION ago
|
||||
concatMap((result) =>
|
||||
iif(
|
||||
() => {
|
||||
const isCacheExpired =
|
||||
Date.now() - (previous?.timestamp ?? 0) > this.UPDATE_INTERVAL_DURATION;
|
||||
return isCacheExpired;
|
||||
},
|
||||
this._updateDailyDataSet().pipe(map(() => ({ meta: result.meta, updated: true }))),
|
||||
of(result),
|
||||
),
|
||||
),
|
||||
concatMap((result) => {
|
||||
if (!result.updated) {
|
||||
this.logService.debug(`[PhishingDataService] No update needed, metadata unchanged`);
|
||||
return of(previous);
|
||||
}
|
||||
|
||||
this.logService.debug(`[PhishingDataService] Updated phishing meta data:`, result.meta);
|
||||
return from(this._phishingMetaState.update(() => result.meta)).pipe(
|
||||
tap(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logService.info(`[PhishingDataService] Updated data set in ${elapsed}ms`);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
takeUntil(this._destroy$),
|
||||
share(),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
// [FIXME] Move compression helpers to a shared utils library
|
||||
// to separate from phishing data service.
|
||||
// ------------------------- Blob and Compression Handling -------------------------
|
||||
private async _compressString(input: string): Promise<string> {
|
||||
try {
|
||||
const stream = new Blob([input]).stream().pipeThrough(new CompressionStream("gzip"));
|
||||
|
||||
const compressedBuffer = await new Response(stream).arrayBuffer();
|
||||
const bytes = new Uint8Array(compressedBuffer);
|
||||
|
||||
// Modern browsers support direct toBase64 conversion
|
||||
// For older support, use fallback
|
||||
return (bytes as any).toBase64
|
||||
? (bytes as any).toBase64()
|
||||
: this._uint8ToBase64Fallback(bytes);
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Compression failed", err);
|
||||
return btoa(encodeURIComponent(input));
|
||||
}
|
||||
}
|
||||
|
||||
private async _decompressString(base64: string): Promise<string> {
|
||||
try {
|
||||
// Modern browsers support direct toBase64 conversion
|
||||
// For older support, use fallback
|
||||
const bytes = (Uint8Array as any).fromBase64
|
||||
? (Uint8Array as any).fromBase64(base64)
|
||||
: this._base64ToUint8Fallback(base64);
|
||||
if (bytes == null) {
|
||||
throw new Error("Base64 decoding resulted in null");
|
||||
}
|
||||
const byteResponse = new Response(bytes);
|
||||
if (!byteResponse.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
const stream = byteResponse.body.pipeThrough(new DecompressionStream("gzip"));
|
||||
const streamResponse = new Response(stream);
|
||||
return await streamResponse.text();
|
||||
} catch (err) {
|
||||
this.logService.error("[PhishingDataService] Decompression failed", err);
|
||||
return decodeURIComponent(atob(base64));
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
|
||||
const chunk = bytes.subarray(i, i + CHUNK_SIZE);
|
||||
binary += String.fromCharCode.apply(null, chunk as any);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
private _base64ToUint8Fallback(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
retry({
|
||||
count: 2, // Total 3 attempts (initial + 2 retries)
|
||||
delay: (error, retryCount) => {
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Attempt ${retryCount} failed. Retrying in 5m...`,
|
||||
error,
|
||||
);
|
||||
return timer(5 * 60 * 1000); // Wait 5 mins before next attempt
|
||||
},
|
||||
}),
|
||||
catchError((err: unknown) => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
this.logService.error(
|
||||
`[PhishingDataService] Retries unsuccessful after ${elapsed}ms.`,
|
||||
err,
|
||||
);
|
||||
return of(previous);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,86 @@ describe("PhishingIndexedDbService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addUrls", () => {
|
||||
it("appends URLs to IndexedDB without clearing", async () => {
|
||||
// Pre-populate store with existing data
|
||||
mockStore.set("https://existing.com", { url: "https://existing.com" });
|
||||
|
||||
const urls = ["https://phishing.com", "https://malware.net"];
|
||||
const result = await service.addUrls(urls);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite");
|
||||
expect(mockObjectStore.clear).not.toHaveBeenCalled();
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
|
||||
// Existing data should still be present
|
||||
expect(mockStore.has("https://existing.com")).toBe(true);
|
||||
expect(mockStore.size).toBe(3);
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles empty array without clearing", async () => {
|
||||
mockStore.set("https://existing.com", { url: "https://existing.com" });
|
||||
|
||||
const result = await service.addUrls([]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockObjectStore.clear).not.toHaveBeenCalled();
|
||||
expect(mockStore.has("https://existing.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("trims whitespace from URLs", async () => {
|
||||
const urls = [" https://example.com ", "\nhttps://test.org\n"];
|
||||
|
||||
await service.addUrls(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.addUrls(urls);
|
||||
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("handles duplicate URLs via upsert", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
|
||||
const urls = [
|
||||
"https://example.com", // Already exists
|
||||
"https://test.org",
|
||||
];
|
||||
|
||||
const result = await service.addUrls(urls);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockObjectStore.put).toHaveBeenCalledTimes(2);
|
||||
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.addUrls(["https://test.com"]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
"[PhishingIndexedDbService] Add failed",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUrl", () => {
|
||||
it("returns true for existing URL", async () => {
|
||||
mockStore.set("https://example.com", { url: "https://example.com" });
|
||||
|
||||
@@ -53,6 +53,9 @@ export class PhishingIndexedDbService {
|
||||
* @returns `true` if save succeeded, `false` on error
|
||||
*/
|
||||
async saveUrls(urls: string[]): Promise<boolean> {
|
||||
this.logService.debug(
|
||||
`[PhishingIndexedDbService] Clearing and saving ${urls.length} to the store...`,
|
||||
);
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
@@ -67,6 +70,29 @@ export class PhishingIndexedDbService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an array of phishing URLs to IndexedDB.
|
||||
* Appends to existing data without clearing.
|
||||
*
|
||||
* @param urls - Array of phishing URLs to add
|
||||
* @returns `true` if add succeeded, `false` on error
|
||||
*/
|
||||
async addUrls(urls: string[]): Promise<boolean> {
|
||||
this.logService.debug(`[PhishingIndexedDbService] Adding ${urls.length} to the store...`);
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
await this.saveChunked(db, urls);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Add failed", error);
|
||||
return false;
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves URLs in chunks to prevent transaction timeouts and UI freezes.
|
||||
*/
|
||||
@@ -100,6 +126,8 @@ export class PhishingIndexedDbService {
|
||||
* @returns `true` if URL exists, `false` if not found or on error
|
||||
*/
|
||||
async hasUrl(url: string): Promise<boolean> {
|
||||
this.logService.debug(`[PhishingIndexedDbService] Checking if store contains ${url}...`);
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
@@ -130,6 +158,8 @@ export class PhishingIndexedDbService {
|
||||
* @returns Array of all stored URLs, or empty array on error
|
||||
*/
|
||||
async loadAllUrls(): Promise<string[]> {
|
||||
this.logService.debug("[PhishingIndexedDbService] Loading all urls from store...");
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
@@ -173,11 +203,16 @@ export class PhishingIndexedDbService {
|
||||
* @returns `true` if save succeeded, `false` on error
|
||||
*/
|
||||
async saveUrlsFromStream(stream: ReadableStream<Uint8Array>): Promise<boolean> {
|
||||
this.logService.debug("[PhishingIndexedDbService] Saving urls to the store from stream...");
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
try {
|
||||
db = await this.openDatabase();
|
||||
await this.clearStore(db);
|
||||
await this.processStream(db, stream);
|
||||
this.logService.info(
|
||||
"[PhishingIndexedDbService] Finished saving urls to the store from stream.",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logService.error("[PhishingIndexedDbService] Stream save failed", error);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<popup-page [loading]="showSpinnerLoaders$ | async" [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-page [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-header slot="header" [pageTitle]="'send' | i18n">
|
||||
<ng-container slot="end">
|
||||
<tools-new-send-dropdown *ngIf="!sendsDisabled"></tools-new-send-dropdown>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
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";
|
||||
@@ -110,7 +109,6 @@ describe("SendV2Component", () => {
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: { hasPremiumFromAnySource$: of(false) },
|
||||
},
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
|
||||
@@ -11,8 +11,6 @@ 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 { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
@@ -84,30 +82,17 @@ export class SendV2Component implements OnDestroy {
|
||||
|
||||
protected listState: SendState | null = null;
|
||||
protected sends$ = this.sendItemsService.filteredAndSortedSends$;
|
||||
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultLoadingSkeletons,
|
||||
);
|
||||
protected sendsLoading$ = this.sendItemsService.loading$.pipe(
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
/** Spinner Loading State */
|
||||
protected showSpinnerLoaders$ = combineLatest([
|
||||
this.sendsLoading$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled));
|
||||
|
||||
/** Skeleton Loading State */
|
||||
protected showSkeletonsLoaders$ = combineLatest([
|
||||
this.sendsLoading$,
|
||||
this.searchService.isSendSearching$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(
|
||||
map(
|
||||
([loading, cipherSearching, skeletonsEnabled]) =>
|
||||
(loading || cipherSearching) && skeletonsEnabled,
|
||||
),
|
||||
map(([loading, cipherSearching]) => loading || cipherSearching),
|
||||
distinctUntilChanged(),
|
||||
skeletonLoadingDelay(),
|
||||
);
|
||||
@@ -128,7 +113,6 @@ export class SendV2Component implements OnDestroy {
|
||||
protected sendListFiltersService: SendListFiltersService,
|
||||
private policyService: PolicyService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private searchService: SearchService,
|
||||
) {
|
||||
combineLatest([
|
||||
|
||||
@@ -277,8 +277,7 @@ export class ItemMoreOptionsComponent {
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
)) as UserId;
|
||||
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(encryptedCipher);
|
||||
await this.cipherService.updateWithServer(cipher, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t(
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FormsModule } from "@angular/forms";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
@@ -20,7 +19,6 @@ describe("VaultV2SearchComponent", () => {
|
||||
|
||||
const searchText$ = new BehaviorSubject("");
|
||||
const loading$ = new BehaviorSubject(false);
|
||||
const featureFlag$ = new BehaviorSubject(true);
|
||||
const applyFilter = jest.fn();
|
||||
|
||||
const createComponent = () => {
|
||||
@@ -31,7 +29,6 @@ describe("VaultV2SearchComponent", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
applyFilter.mockClear();
|
||||
featureFlag$.next(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule],
|
||||
@@ -49,12 +46,6 @@ describe("VaultV2SearchComponent", () => {
|
||||
loading$,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
getFeatureFlag$: jest.fn(() => featureFlag$),
|
||||
},
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
],
|
||||
}).compileComponents();
|
||||
@@ -70,91 +61,55 @@ describe("VaultV2SearchComponent", () => {
|
||||
});
|
||||
|
||||
describe("debouncing behavior", () => {
|
||||
describe("when feature flag is enabled", () => {
|
||||
beforeEach(() => {
|
||||
featureFlag$.next(true);
|
||||
createComponent();
|
||||
});
|
||||
|
||||
it("debounces search text changes when not loading", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("should not debounce search text changes when loading", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(0);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("cancels previous debounce when new text is entered", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
component.searchText = "test2";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test2");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
describe("when feature flag is disabled", () => {
|
||||
beforeEach(() => {
|
||||
featureFlag$.next(false);
|
||||
createComponent();
|
||||
});
|
||||
it("debounces search text changes when not loading", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
it("debounces search text changes", fakeAsync(() => {
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
tick(SearchTextDebounceInterval);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
it("ignores loading state and always debounces", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
it("should not debounce search text changes when loading", fakeAsync(() => {
|
||||
loading$.next(true);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
tick(0);
|
||||
|
||||
tick(SearchTextDebounceInterval);
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
});
|
||||
it("cancels previous debounce when new text is entered", fakeAsync(() => {
|
||||
loading$.next(false);
|
||||
|
||||
component.searchText = "test";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
component.searchText = "test2";
|
||||
component.onSearchTextChanged();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).not.toHaveBeenCalled();
|
||||
|
||||
tick(SearchTextDebounceInterval / 2);
|
||||
|
||||
expect(applyFilter).toHaveBeenCalledWith("test2");
|
||||
expect(applyFilter).toHaveBeenCalledTimes(1);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,13 @@ import {
|
||||
Subscription,
|
||||
combineLatest,
|
||||
debounce,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
switchMap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
|
||||
@@ -40,7 +36,6 @@ export class VaultV2SearchComponent {
|
||||
constructor(
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupLoadingService: VaultPopupLoadingService,
|
||||
private configService: ConfigService,
|
||||
private ngZone: NgZone,
|
||||
) {
|
||||
this.subscribeToLatestSearchText();
|
||||
@@ -63,31 +58,19 @@ export class VaultV2SearchComponent {
|
||||
}
|
||||
|
||||
subscribeToApplyFilter(): void {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons)
|
||||
combineLatest([this.searchText$, this.loading$])
|
||||
.pipe(
|
||||
switchMap((enabled) => {
|
||||
if (!enabled) {
|
||||
return this.searchText$.pipe(
|
||||
debounceTime(SearchTextDebounceInterval),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
return combineLatest([this.searchText$, this.loading$]).pipe(
|
||||
debounce(([_, isLoading]) => {
|
||||
// If loading apply immediately to avoid stale searches.
|
||||
// After loading completes, debounce to avoid excessive searches.
|
||||
const delayTime = isLoading ? 0 : SearchTextDebounceInterval;
|
||||
return timer(delayTime);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
([prevText, prevLoading], [newText, newLoading]) =>
|
||||
prevText === newText && prevLoading === newLoading,
|
||||
),
|
||||
map(([text, _]) => text),
|
||||
);
|
||||
debounce(([_, isLoading]) => {
|
||||
// If loading apply immediately to avoid stale searches.
|
||||
// After loading completes, debounce to avoid excessive searches.
|
||||
const delayTime = isLoading ? 0 : SearchTextDebounceInterval;
|
||||
return timer(delayTime);
|
||||
}),
|
||||
distinctUntilChanged(
|
||||
([prevText, prevLoading], [newText, newLoading]) =>
|
||||
prevText === newText && prevLoading === newLoading,
|
||||
),
|
||||
map(([text, _]) => text),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((text) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<popup-page [loading]="showSpinnerLoaders$ | async" [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-page [hideOverflow]="showSkeletonsLoaders$ | async">
|
||||
<popup-header slot="header" [pageTitle]="'vault' | i18n">
|
||||
<ng-container slot="end">
|
||||
<app-new-item-dropdown [initialValues]="newItemItemValues$ | async"></app-new-item-dropdown>
|
||||
@@ -8,37 +8,28 @@
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
|
||||
<ng-template #emptyVaultTemplate>
|
||||
<div
|
||||
*ngIf="vaultState === VaultStateEnum.Empty"
|
||||
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
|
||||
>
|
||||
<bit-no-items [icon]="vaultIcon">
|
||||
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
|
||||
<ng-container slot="description">
|
||||
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">
|
||||
{{ "emptyVaultDescription" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<a
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="{ prefillNameAndURIFromTab: true }"
|
||||
>
|
||||
{{ "newLogin" | i18n }}
|
||||
</a>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@if (skeletonFeatureFlag$ | async) {
|
||||
<vault-fade-in-out *ngIf="vaultState === VaultStateEnum.Empty">
|
||||
<ng-container *ngTemplateOutlet="emptyVaultTemplate"></ng-container>
|
||||
@if (vaultState === VaultStateEnum.Empty) {
|
||||
<vault-fade-in-out>
|
||||
<div class="tw-flex tw-flex-col tw-h-full tw-justify-center">
|
||||
<bit-no-items [icon]="vaultIcon">
|
||||
<ng-container slot="title">{{ "yourVaultIsEmpty" | i18n }}</ng-container>
|
||||
<ng-container slot="description">
|
||||
<p bitTypography="body2" class="tw-mx-6 tw-mt-2">
|
||||
{{ "emptyVaultDescription" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<a
|
||||
slot="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="{ prefillNameAndURIFromTab: true }"
|
||||
>
|
||||
{{ "newLogin" | i18n }}
|
||||
</a>
|
||||
</bit-no-items>
|
||||
</div>
|
||||
</vault-fade-in-out>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="emptyVaultTemplate"></ng-container>
|
||||
}
|
||||
|
||||
<blocked-injection-banner
|
||||
@@ -113,31 +104,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #vaultContentTemplate>
|
||||
<ng-container *ngIf="vaultState === null && !(loading$ | async)">
|
||||
<app-autofill-vault-list-items></app-autofill-vault-list-items>
|
||||
<app-vault-list-items-container
|
||||
[title]="'favorites' | i18n"
|
||||
[ciphers]="(favoriteCiphers$ | async) || []"
|
||||
id="favorites"
|
||||
collapsibleKey="favorites"
|
||||
></app-vault-list-items-container>
|
||||
<app-vault-list-items-container
|
||||
[title]="'allItems' | i18n"
|
||||
[ciphers]="(remainingCiphers$ | async) || []"
|
||||
id="allItems"
|
||||
disableSectionMargin
|
||||
collapsibleKey="allItems"
|
||||
></app-vault-list-items-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@if (skeletonFeatureFlag$ | async) {
|
||||
<vault-fade-in-out *ngIf="vaultState === null">
|
||||
<ng-container *ngTemplateOutlet="vaultContentTemplate"></ng-container>
|
||||
@if (vaultState === null) {
|
||||
<vault-fade-in-out>
|
||||
@if (!(loading$ | async)) {
|
||||
<app-autofill-vault-list-items></app-autofill-vault-list-items>
|
||||
<app-vault-list-items-container
|
||||
[title]="'favorites' | i18n"
|
||||
[ciphers]="(favoriteCiphers$ | async) || []"
|
||||
id="favorites"
|
||||
collapsibleKey="favorites"
|
||||
></app-vault-list-items-container>
|
||||
<app-vault-list-items-container
|
||||
[title]="'allItems' | i18n"
|
||||
[ciphers]="(remainingCiphers$ | async) || []"
|
||||
id="allItems"
|
||||
disableSectionMargin
|
||||
collapsibleKey="allItems"
|
||||
></app-vault-list-items-container>
|
||||
}
|
||||
</vault-fade-in-out>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="vaultContentTemplate"></ng-container>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { mock } from "jest-mock-extended";
|
||||
@@ -243,6 +244,7 @@ describe("VaultV2Component", () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultV2Component, RouterTestingModule],
|
||||
providers: [
|
||||
provideNoopAnimations(),
|
||||
{ provide: VaultPopupItemsService, useValue: itemsSvc },
|
||||
{ provide: VaultPopupListFiltersService, useValue: filtersSvc },
|
||||
{ provide: VaultPopupScrollPositionService, useValue: scrollSvc },
|
||||
|
||||
@@ -158,10 +158,6 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
}),
|
||||
);
|
||||
|
||||
protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.VaultLoadingSkeletons,
|
||||
);
|
||||
|
||||
protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.BrowserPremiumSpotlight,
|
||||
);
|
||||
@@ -216,20 +212,14 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
PremiumUpgradeDialogComponent.open(this.dialogService);
|
||||
}
|
||||
|
||||
/** When true, show spinner loading state */
|
||||
protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
|
||||
map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
|
||||
);
|
||||
|
||||
/** When true, show skeleton loading state with debouncing to prevent flicker */
|
||||
protected showSkeletonsLoaders$ = combineLatest([
|
||||
this.loading$,
|
||||
this.searchService.isCipherSearching$,
|
||||
this.vaultItemsTransferService.transferInProgress$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(
|
||||
map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => {
|
||||
return (loading || cipherSearching || transferInProgress) && skeletonsEnabled;
|
||||
map(([loading, cipherSearching, transferInProgress]) => {
|
||||
return loading || cipherSearching || transferInProgress;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
skeletonLoadingDelay(),
|
||||
|
||||
@@ -378,8 +378,7 @@ describe("VaultPopupAutofillService", () => {
|
||||
expect(result).toBe(true);
|
||||
expect(mockCipher.login.uris).toHaveLength(1);
|
||||
expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url);
|
||||
expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher, mockUserId);
|
||||
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher);
|
||||
expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockCipher, mockUserId);
|
||||
});
|
||||
|
||||
it("should add a URI to the cipher when there are no existing URIs", async () => {
|
||||
|
||||
@@ -426,8 +426,7 @@ export class VaultPopupAutofillService {
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
await this.cipherService.updateWithServer(cipher, activeUserId);
|
||||
this.messagingService.send("editedCipher");
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/cli",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
@@ -81,7 +81,7 @@
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-forge": "1.3.2",
|
||||
"open": "11.0.0",
|
||||
"papaparse": "5.5.3",
|
||||
|
||||
@@ -138,10 +138,8 @@ export class EditCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
try {
|
||||
const updatedCipher = await this.cipherService.updateWithServer(encCipher);
|
||||
const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId);
|
||||
const decCipher = await this.cipherService.updateWithServer(cipherView, activeUserId);
|
||||
const res = new CipherResponse(decCipher);
|
||||
return Response.success(res);
|
||||
} catch (e) {
|
||||
|
||||
@@ -147,11 +147,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
@@ -254,6 +256,7 @@ export class ServiceContainer {
|
||||
twoFactorApiService: TwoFactorApiService;
|
||||
hibpApiService: HibpApiService;
|
||||
environmentService: EnvironmentService;
|
||||
cipherSdkService: CipherSdkService;
|
||||
cipherService: CipherService;
|
||||
folderService: InternalFolderService;
|
||||
organizationUserApiService: OrganizationUserApiService;
|
||||
@@ -794,6 +797,8 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
|
||||
|
||||
this.cipherService = new CipherService(
|
||||
this.keyService,
|
||||
this.domainSettingsService,
|
||||
@@ -809,6 +814,7 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
this.cipherSdkService,
|
||||
);
|
||||
|
||||
this.cipherArchiveService = new DefaultCipherArchiveService(
|
||||
|
||||
@@ -103,10 +103,11 @@ export class CreateCommand {
|
||||
return Response.error("Creating this item type is restricted by organizational policy.");
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
|
||||
const newCipher = await this.cipherService.createWithServer(cipher);
|
||||
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
|
||||
const res = new CipherResponse(decCipher);
|
||||
const newCipher = await this.cipherService.createWithServer(
|
||||
CipherExport.toView(req),
|
||||
activeUserId,
|
||||
);
|
||||
const res = new CipherResponse(newCipher);
|
||||
return Response.success(res);
|
||||
} catch (e) {
|
||||
return Response.error(e);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
|
||||
@@ -299,12 +299,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
throw new Error("No active user ID found!");
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
|
||||
try {
|
||||
const createdCipher = await this.cipherService.createWithServer(encCipher);
|
||||
const createdCipher = await this.cipherService.createWithServer(cipher, activeUserId);
|
||||
const encryptedCreatedCipher = await this.cipherService.encrypt(createdCipher, activeUserId);
|
||||
|
||||
return createdCipher;
|
||||
return encryptedCreatedCipher.cipher;
|
||||
} catch {
|
||||
throw new Error("Unable to create cipher");
|
||||
}
|
||||
@@ -316,8 +315,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
this.accountService.activeAccount$.pipe(
|
||||
map(async (a) => {
|
||||
if (a) {
|
||||
const encCipher = await this.cipherService.encrypt(cipher, a.id);
|
||||
await this.cipherService.updateWithServer(encCipher);
|
||||
await this.cipherService.updateWithServer(cipher, a.id);
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
4
apps/desktop/src/package-lock.json
generated
4
apps/desktop/src/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@bitwarden/desktop-napi": "file:../desktop_native/napi"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/desktop",
|
||||
"productName": "Bitwarden",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
|
||||
"homepage": "https://bitwarden.com",
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -166,8 +166,7 @@ export class EncryptedMessageHandlerService {
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
const encrypted = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
await this.cipherService.createWithServer(encrypted);
|
||||
await this.cipherService.createWithServer(cipherView, activeUserId);
|
||||
|
||||
// Notify other clients of new login
|
||||
await this.messagingService.send("addedCipher");
|
||||
@@ -212,9 +211,8 @@ export class EncryptedMessageHandlerService {
|
||||
cipherView.login.password = credentialUpdatePayload.password;
|
||||
cipherView.login.username = credentialUpdatePayload.userName;
|
||||
cipherView.login.uris[0].uri = credentialUpdatePayload.uri;
|
||||
const encrypted = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
|
||||
await this.cipherService.updateWithServer(encrypted);
|
||||
await this.cipherService.updateWithServer(cipherView, activeUserId);
|
||||
|
||||
// Notify other clients of update
|
||||
await this.messagingService.send("editedCipher");
|
||||
|
||||
@@ -813,6 +813,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
|
||||
};
|
||||
return filterFn(proxyCipher as any);
|
||||
}
|
||||
return filterFn(cipher);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -642,77 +642,80 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
});
|
||||
}
|
||||
|
||||
switch (cipher.type) {
|
||||
case CipherType.Login:
|
||||
if (
|
||||
cipher.login.canLaunch ||
|
||||
cipher.login.username != null ||
|
||||
cipher.login.password != null
|
||||
) {
|
||||
menu.push({ type: "separator" });
|
||||
}
|
||||
if (cipher.login.canLaunch) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("launch"),
|
||||
click: () => this.platformUtilsService.launchUri(cipher.login.launchUri),
|
||||
});
|
||||
}
|
||||
if (cipher.login.username != null) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyUsername"),
|
||||
click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"),
|
||||
});
|
||||
}
|
||||
if (cipher.login.password != null && cipher.viewPassword) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyPassword"),
|
||||
click: () => {
|
||||
this.copyValue(cipher, cipher.login.password, "password", "Password");
|
||||
this.eventCollectionService
|
||||
.collect(EventType.Cipher_ClientCopiedPassword, cipher.id)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyVerificationCodeTotp"),
|
||||
click: async () => {
|
||||
const value = await firstValueFrom(
|
||||
this.totpService.getCode$(cipher.login.totp),
|
||||
).catch((): any => null);
|
||||
if (value) {
|
||||
this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
case CipherType.Card:
|
||||
if (cipher.card.number != null || cipher.card.code != null) {
|
||||
menu.push({ type: "separator" });
|
||||
}
|
||||
if (cipher.card.number != null) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyNumber"),
|
||||
click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"),
|
||||
});
|
||||
}
|
||||
if (cipher.card.code != null) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copySecurityCode"),
|
||||
click: () => {
|
||||
this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code");
|
||||
this.eventCollectionService
|
||||
.collect(EventType.Cipher_ClientCopiedCardCode, cipher.id)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (!cipher.isDeleted) {
|
||||
switch (cipher.type) {
|
||||
case CipherType.Login:
|
||||
if (
|
||||
cipher.login.canLaunch ||
|
||||
cipher.login.username != null ||
|
||||
cipher.login.password != null
|
||||
) {
|
||||
menu.push({ type: "separator" });
|
||||
}
|
||||
if (cipher.login.canLaunch) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("launch"),
|
||||
click: () => this.platformUtilsService.launchUri(cipher.login.launchUri),
|
||||
});
|
||||
}
|
||||
if (cipher.login.username != null) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyUsername"),
|
||||
click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"),
|
||||
});
|
||||
}
|
||||
if (cipher.login.password != null && cipher.viewPassword) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyPassword"),
|
||||
click: () => {
|
||||
this.copyValue(cipher, cipher.login.password, "password", "Password");
|
||||
this.eventCollectionService
|
||||
.collect(EventType.Cipher_ClientCopiedPassword, cipher.id)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyVerificationCodeTotp"),
|
||||
click: async () => {
|
||||
const value = await firstValueFrom(
|
||||
this.totpService.getCode$(cipher.login.totp),
|
||||
).catch((): any => null);
|
||||
if (value) {
|
||||
this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
case CipherType.Card:
|
||||
if (cipher.card.number != null || cipher.card.code != null) {
|
||||
menu.push({ type: "separator" });
|
||||
}
|
||||
if (cipher.card.number != null) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copyNumber"),
|
||||
click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"),
|
||||
});
|
||||
}
|
||||
if (cipher.card.code != null) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("copySecurityCode"),
|
||||
click: () => {
|
||||
this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code");
|
||||
this.eventCollectionService
|
||||
.collect(EventType.Cipher_ClientCopiedCardCode, cipher.id)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
invokeMenu(menu);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/web-vault",
|
||||
"version": "2026.1.0",
|
||||
"version": "2026.1.1",
|
||||
"scripts": {
|
||||
"build:oss": "webpack",
|
||||
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",
|
||||
|
||||
@@ -46,8 +46,11 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
organizations: Organization[] = [];
|
||||
organizations$: Observable<Organization[]>;
|
||||
|
||||
readonly maxItemsToSwitchToChipSelect = 5;
|
||||
filterStatus: any = [0];
|
||||
showFilterToggle: boolean = false;
|
||||
selectedFilterChip: string = "0";
|
||||
chipSelectOptions: { label: string; value: string }[] = [];
|
||||
vaultMsg: string = "vault";
|
||||
currentFilterStatus: number | string = 0;
|
||||
protected filterOrgStatus$ = new BehaviorSubject<number | string>(0);
|
||||
@@ -190,6 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
formConfig,
|
||||
activeCollectionId,
|
||||
disableForm,
|
||||
isAdminConsoleAction: true,
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(this.vaultItemDialogRef.closed);
|
||||
@@ -288,6 +292,15 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
return await this.cipherService.getAllDecrypted(activeUserId);
|
||||
}
|
||||
|
||||
protected canDisplayToggleGroup(): boolean {
|
||||
return this.filterStatus.length <= this.maxItemsToSwitchToChipSelect;
|
||||
}
|
||||
|
||||
async filterOrgToggleChipSelect(filterId: string | null) {
|
||||
const selectedFilterId = filterId ?? 0;
|
||||
await this.filterOrgToggle(selectedFilterId);
|
||||
}
|
||||
|
||||
protected filterCiphersByOrg(ciphersList: CipherView[]) {
|
||||
this.allCiphers = [...ciphersList];
|
||||
|
||||
@@ -309,5 +322,22 @@ export abstract class CipherReportComponent implements OnDestroy {
|
||||
this.showFilterToggle = false;
|
||||
this.vaultMsg = "vault";
|
||||
}
|
||||
|
||||
this.chipSelectOptions = this.setupChipSelectOptions(this.filterStatus);
|
||||
}
|
||||
|
||||
private setupChipSelectOptions(filters: string[]) {
|
||||
const options = filters.map((filterId: string, index: number) => {
|
||||
const name = this.getName(filterId);
|
||||
const count = this.getCount(filterId);
|
||||
const labelSuffix = count != null ? ` (${count})` : "";
|
||||
|
||||
return {
|
||||
label: name + labelSuffix,
|
||||
value: filterId,
|
||||
};
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,32 @@
|
||||
<bit-callout type="danger" title="{{ 'exposedPasswordsFound' | i18n }}" [useAlertRole]="true">
|
||||
{{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
|
||||
@@ -18,19 +18,32 @@
|
||||
<bit-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
|
||||
{{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
PasswordRepromptService,
|
||||
CipherFormConfigService,
|
||||
@@ -45,7 +45,7 @@ import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class ExposedPasswordsReportComponent
|
||||
extends BaseExposedPasswordsReportComponent
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -39,7 +39,7 @@ import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponen
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class InactiveTwoFactorReportComponent
|
||||
extends BaseInactiveTwoFactorReportComponent
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -44,7 +44,7 @@ import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent }
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class ReusedPasswordsReportComponent
|
||||
extends BaseReusedPasswordsReportComponent
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -44,7 +44,7 @@ import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponen
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class UnsecuredWebsitesReportComponent
|
||||
extends BaseUnsecuredWebsitesReportComponent
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { ChipSelectComponent, DialogService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
@@ -45,7 +45,7 @@ import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule],
|
||||
imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent],
|
||||
})
|
||||
export class WeakPasswordsReportComponent
|
||||
extends BaseWeakPasswordsReportComponent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -9,9 +9,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { reports, ReportType } from "../reports";
|
||||
import { ReportEntry, ReportVariant } from "../shared";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-reports-home",
|
||||
templateUrl: "reports-home.component.html",
|
||||
standalone: false,
|
||||
|
||||
@@ -19,19 +19,30 @@
|
||||
{{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
|
||||
@@ -19,19 +19,31 @@
|
||||
{{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header *ngIf="!isAdminConsoleActive">
|
||||
<th bitCell></th>
|
||||
|
||||
@@ -18,19 +18,32 @@
|
||||
<bit-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
|
||||
{{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
|
||||
</bit-callout>
|
||||
<bit-toggle-group
|
||||
*ngIf="showFilterToggle && !isAdminConsoleActive"
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
|
||||
@if (showFilterToggle && !isAdminConsoleActive) {
|
||||
@if (canDisplayToggleGroup()) {
|
||||
<bit-toggle-group
|
||||
[selected]="filterOrgStatus$ | async"
|
||||
(selectedChange)="filterOrgToggle($event)"
|
||||
[attr.aria-label]="'addAccessFilter' | i18n"
|
||||
>
|
||||
<ng-container *ngFor="let status of filterStatus">
|
||||
<bit-toggle [value]="status">
|
||||
{{ getName(status) }}
|
||||
<span bitBadge variant="info"> {{ getCount(status) }} </span>
|
||||
</bit-toggle>
|
||||
</ng-container>
|
||||
</bit-toggle-group>
|
||||
} @else {
|
||||
<bit-chip-select
|
||||
[placeholderText]="chipSelectOptions[0].label"
|
||||
[options]="chipSelectOptions"
|
||||
[ngModel]="selectedFilterChip"
|
||||
(ngModelChange)="filterOrgToggleChipSelect($event)"
|
||||
fullWidth="true"
|
||||
></bit-chip-select>
|
||||
}
|
||||
}
|
||||
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
|
||||
<ng-container header>
|
||||
<th bitCell></th>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { ChipSelectComponent } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfigService,
|
||||
DefaultCipherFormConfigService,
|
||||
@@ -34,6 +35,7 @@ import { ReportsSharedModule } from "./shared";
|
||||
OrganizationBadgeModule,
|
||||
PipesModule,
|
||||
HeaderModule,
|
||||
ChipSelectComponent,
|
||||
],
|
||||
declarations: [
|
||||
BreachReportComponent,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ title }}
|
||||
</span>
|
||||
|
||||
@if (isCipherArchived) {
|
||||
@if (isCipherArchived && !params.isAdminConsoleAction) {
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,25 @@ describe("VaultItemDialogComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive badge", () => {
|
||||
it('should show "archived" badge when the item is archived and not an admin console action', () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "view" });
|
||||
fixture.detectChanges();
|
||||
const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]"));
|
||||
expect(archivedBadge).toBeTruthy();
|
||||
expect(archivedBadge.nativeElement.textContent.trim()).toBe("archived");
|
||||
});
|
||||
|
||||
it('should not show "archived" badge when the item is archived and is an admin console action', () => {
|
||||
component.setTestCipher({ isArchived: true });
|
||||
component.setTestParams({ mode: "view", isAdminConsoleAction: true });
|
||||
fixture.detectChanges();
|
||||
const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]"));
|
||||
expect(archivedBadge).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitButtonText$", () => {
|
||||
it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => {
|
||||
jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false));
|
||||
|
||||
@@ -1536,8 +1536,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const cipherFullView = await this.cipherService.getFullCipherView(cipher);
|
||||
cipherFullView.favorite = !cipherFullView.favorite;
|
||||
const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId);
|
||||
await this.cipherService.updateWithServer(encryptedCipher);
|
||||
await this.cipherService.updateWithServer(cipherFullView, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -303,6 +303,7 @@ import {
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -321,6 +322,7 @@ import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
@@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultDomainSettingsService,
|
||||
deps: [StateProvider, PolicyServiceAbstraction, AccountService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherSdkService,
|
||||
useClass: DefaultCipherSdkService,
|
||||
deps: [SdkService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherServiceAbstraction,
|
||||
useFactory: (
|
||||
@@ -607,6 +614,7 @@ const safeProviders: SafeProvider[] = [
|
||||
logService: LogService,
|
||||
cipherEncryptionService: CipherEncryptionService,
|
||||
messagingService: MessagingServiceAbstraction,
|
||||
cipherSdkService: CipherSdkService,
|
||||
) =>
|
||||
new CipherService(
|
||||
keyService,
|
||||
@@ -623,6 +631,7 @@ const safeProviders: SafeProvider[] = [
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messagingService,
|
||||
cipherSdkService,
|
||||
),
|
||||
deps: [
|
||||
KeyService,
|
||||
@@ -639,6 +648,7 @@ const safeProviders: SafeProvider[] = [
|
||||
LogService,
|
||||
CipherEncryptionService,
|
||||
MessagingServiceAbstraction,
|
||||
CipherSdkService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -66,10 +66,9 @@ export enum FeatureFlag {
|
||||
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -130,9 +129,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
|
||||
@@ -254,17 +254,17 @@ describe("FidoAuthenticatorService", () => {
|
||||
}
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext);
|
||||
|
||||
await authenticator.makeCredential(params, windowReference);
|
||||
|
||||
const saved = cipherService.encrypt.mock.lastCall?.[0];
|
||||
expect(saved).toEqual(
|
||||
const savedCipher = cipherService.updateWithServer.mock.lastCall?.[0];
|
||||
const actualUserId = cipherService.updateWithServer.mock.lastCall?.[1];
|
||||
expect(actualUserId).toEqual(userId);
|
||||
expect(savedCipher).toEqual(
|
||||
expect.objectContaining({
|
||||
type: CipherType.Login,
|
||||
name: existingCipher.name,
|
||||
@@ -288,7 +288,6 @@ describe("FidoAuthenticatorService", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher);
|
||||
});
|
||||
|
||||
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||
@@ -361,17 +360,14 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.decrypt.mockResolvedValue(cipher);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return { cipher: {} as any as Cipher, encryptedFor: userId };
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
cipherService.createWithServer.mockImplementation(async (cipherView, _userId) => {
|
||||
cipherView.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async ({ cipher }) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
cipherService.updateWithServer.mockImplementation(async (cipherView, _userId) => {
|
||||
cipherView.id = cipherId;
|
||||
cipherView.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability
|
||||
return cipherView;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -701,14 +697,11 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: Increment the credential associated signature counter */
|
||||
it("should increment counter and save to server when stored counter is larger than zero", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
ciphers[0].login.fido2Credentials[0].counter = 9000;
|
||||
|
||||
await authenticator.getAssertion(params, windowReference);
|
||||
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
|
||||
expect(cipherService.encrypt).toHaveBeenCalledWith(
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ciphers[0].id,
|
||||
login: expect.objectContaining({
|
||||
@@ -725,8 +718,6 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** Spec: Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero. */
|
||||
it("should not save to server when stored counter is zero", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
ciphers[0].login.fido2Credentials[0].counter = 0;
|
||||
|
||||
await authenticator.getAssertion(params, windowReference);
|
||||
|
||||
@@ -187,8 +187,7 @@ export class Fido2AuthenticatorService<
|
||||
if (Utils.isNullOrEmpty(cipher.login.username)) {
|
||||
cipher.login.username = fido2Credential.userName;
|
||||
}
|
||||
const reencrypted = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(reencrypted);
|
||||
await this.cipherService.updateWithServer(cipher, activeUserId);
|
||||
await this.cipherService.clearCache(activeUserId);
|
||||
credentialId = fido2Credential.credentialId;
|
||||
} catch (error) {
|
||||
@@ -328,8 +327,7 @@ export class Fido2AuthenticatorService<
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
);
|
||||
const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId);
|
||||
await this.cipherService.updateWithServer(encrypted);
|
||||
await this.cipherService.updateWithServer(selectedCipher, activeUserId);
|
||||
await this.cipherService.clearCache(activeUserId);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export class DefaultSdkService implements SdkService {
|
||||
client$ = this.environmentService.environment$.pipe(
|
||||
concatMap(async (env) => {
|
||||
await SdkLoadService.Ready;
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService),
|
||||
settings,
|
||||
@@ -210,7 +210,7 @@ export class DefaultSdkService implements SdkService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
@@ -322,11 +322,12 @@ export class DefaultSdkService implements SdkService {
|
||||
client.platform().load_flags(featureFlagMap);
|
||||
}
|
||||
|
||||
private toSettings(env: Environment): ClientSettings {
|
||||
private async toSettings(env: Environment): Promise<ClientSettings> {
|
||||
return {
|
||||
apiUrl: env.getApiUrl(),
|
||||
identityUrl: env.getIdentityUrl(),
|
||||
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
|
||||
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
|
||||
userAgent: this.userAgent ?? navigator.userAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
client$ = this.environmentService.environment$.pipe(
|
||||
concatMap(async (env) => {
|
||||
await SdkLoadService.Ready;
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService),
|
||||
settings,
|
||||
@@ -137,7 +137,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const settings = this.toSettings(env);
|
||||
const settings = await this.toSettings(env);
|
||||
const client = await this.sdkClientFactory.createSdkClient(
|
||||
new JsTokenProvider(this.apiService, userId),
|
||||
settings,
|
||||
@@ -185,12 +185,13 @@ export class DefaultRegisterSdkService implements RegisterSdkService {
|
||||
client.platform().load_flags(featureFlagMap);
|
||||
}
|
||||
|
||||
private toSettings(env: Environment): ClientSettings {
|
||||
private async toSettings(env: Environment): Promise<ClientSettings> {
|
||||
return {
|
||||
apiUrl: env.getApiUrl(),
|
||||
identityUrl: env.getIdentityUrl(),
|
||||
deviceType: toSdkDevice(this.platformUtilsService.getDevice()),
|
||||
userAgent: this.userAgent ?? navigator.userAgent,
|
||||
bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
37
libs/common/src/vault/abstractions/cipher-sdk.service.ts
Normal file
37
libs/common/src/vault/abstractions/cipher-sdk.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
/**
|
||||
* Service responsible for cipher operations using the SDK.
|
||||
*/
|
||||
export abstract class CipherSdkService {
|
||||
/**
|
||||
* Creates a new cipher on the server using the SDK.
|
||||
*
|
||||
* @param cipherView The cipher view to create
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param orgAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves to the created cipher view
|
||||
*/
|
||||
abstract createWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined>;
|
||||
|
||||
/**
|
||||
* Updates a cipher on the server using the SDK.
|
||||
*
|
||||
* @param cipher The cipher view to update
|
||||
* @param userId The user ID to use for SDK client
|
||||
* @param originalCipherView The original cipher view before changes (optional, used for admin operations)
|
||||
* @param orgAdmin Whether this is an organization admin operation
|
||||
* @returns A promise that resolves to the updated cipher view
|
||||
*/
|
||||
abstract updateWithServer(
|
||||
cipher: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined>;
|
||||
}
|
||||
@@ -119,9 +119,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* @returns A promise that resolves to the created cipher
|
||||
*/
|
||||
abstract createWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher>;
|
||||
): Promise<CipherView>;
|
||||
|
||||
/**
|
||||
* Update a cipher with the server
|
||||
* @param cipher The cipher to update
|
||||
@@ -131,10 +133,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
* @returns A promise that resolves to the updated cipher
|
||||
*/
|
||||
abstract updateWithServer(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
isNotClone?: boolean,
|
||||
): Promise<Cipher>;
|
||||
): Promise<CipherView>;
|
||||
|
||||
/**
|
||||
* Move a cipher to an organization by re-encrypting its keys with the organization's key.
|
||||
|
||||
@@ -353,4 +353,366 @@ describe("CipherView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Note: These tests use jest.requireActual() because the file has jest.mock() calls
|
||||
// at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests
|
||||
// but interfere with these tests which need the real implementations.
|
||||
describe("toSdkCreateCipherRequest", () => {
|
||||
it("maps all properties correctly for a login cipher", () => {
|
||||
const { FieldView: RealFieldView } = jest.requireActual("./field.view");
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
|
||||
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
|
||||
cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"];
|
||||
cipherView.name = "Test Login";
|
||||
cipherView.notes = "Test notes";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.favorite = true;
|
||||
cipherView.reprompt = CipherRepromptType.Password;
|
||||
|
||||
const field = new RealFieldView();
|
||||
field.name = "testField";
|
||||
field.value = "testValue";
|
||||
field.type = SdkFieldType.Text;
|
||||
cipherView.fields = [field];
|
||||
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.login.username = "testuser";
|
||||
cipherView.login.password = "testpass";
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c"));
|
||||
expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f"));
|
||||
expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]);
|
||||
expect(result.name).toBe("Test Login");
|
||||
expect(result.notes).toBe("Test notes");
|
||||
expect(result.favorite).toBe(true);
|
||||
expect(result.reprompt).toBe(CipherRepromptType.Password);
|
||||
expect(result.fields).toHaveLength(1);
|
||||
expect(result.fields![0]).toMatchObject({
|
||||
name: "testField",
|
||||
value: "testValue",
|
||||
type: SdkFieldType.Text,
|
||||
});
|
||||
expect(result.type).toHaveProperty("login");
|
||||
expect((result.type as any).login).toMatchObject({
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles undefined organizationId and folderId", () => {
|
||||
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.SecureNote;
|
||||
cipherView.secureNote = new RealSecureNoteView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.organizationId).toBeUndefined();
|
||||
expect(result.folderId).toBeUndefined();
|
||||
expect(result.name).toBe("Test Cipher");
|
||||
});
|
||||
|
||||
it("handles empty collectionIds array", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.collectionIds = [];
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.collectionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults favorite to false when undefined", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.favorite = undefined as any;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.favorite).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults reprompt to None when undefined", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.reprompt = undefined as any;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
expect(result.reprompt).toBe(CipherRepromptType.None);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Login", CipherType.Login, "login.view", "LoginView"],
|
||||
["Card", CipherType.Card, "card.view", "CardView"],
|
||||
["Identity", CipherType.Identity, "identity.view", "IdentityView"],
|
||||
["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"],
|
||||
["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"],
|
||||
])(
|
||||
"creates correct type property for %s cipher",
|
||||
(typeName: string, cipherType: CipherType, moduleName: string, className: string) => {
|
||||
const module = jest.requireActual(`./${moduleName}`);
|
||||
const ViewClass = module[className];
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = `Test ${typeName}`;
|
||||
cipherView.type = cipherType;
|
||||
|
||||
// Set the appropriate view property
|
||||
const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
(cipherView as any)[viewPropertyName] = new ViewClass();
|
||||
|
||||
const result = cipherView.toSdkCreateCipherRequest();
|
||||
|
||||
const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
expect(result.type).toHaveProperty(typeKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("toSdkUpdateCipherRequest", () => {
|
||||
it("maps all properties correctly for an update request", () => {
|
||||
const { FieldView: RealFieldView } = jest.requireActual("./field.view");
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c";
|
||||
cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f";
|
||||
cipherView.name = "Updated Login";
|
||||
cipherView.notes = "Updated notes";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.favorite = true;
|
||||
cipherView.reprompt = CipherRepromptType.Password;
|
||||
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
|
||||
cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z");
|
||||
cipherView.key = new EncString("cipher-key");
|
||||
|
||||
const mockField = new RealFieldView();
|
||||
mockField.name = "testField";
|
||||
mockField.value = "testValue";
|
||||
cipherView.fields = [mockField];
|
||||
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.login.username = "testuser";
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"));
|
||||
expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c"));
|
||||
expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f"));
|
||||
expect(result.name).toBe("Updated Login");
|
||||
expect(result.notes).toBe("Updated notes");
|
||||
expect(result.favorite).toBe(true);
|
||||
expect(result.reprompt).toBe(CipherRepromptType.Password);
|
||||
expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z");
|
||||
expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z");
|
||||
expect(result.fields).toHaveLength(1);
|
||||
expect(result.fields![0]).toMatchObject({
|
||||
name: "testField",
|
||||
value: "testValue",
|
||||
});
|
||||
expect(result.type).toHaveProperty("login");
|
||||
expect((result.type as any).login).toMatchObject({
|
||||
username: "testuser",
|
||||
});
|
||||
expect(result.key).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles undefined optional properties", () => {
|
||||
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.SecureNote;
|
||||
cipherView.secureNote = new RealSecureNoteView();
|
||||
cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z");
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.organizationId).toBeUndefined();
|
||||
expect(result.folderId).toBeUndefined();
|
||||
expect(result.archivedDate).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("converts dates to ISO strings", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z");
|
||||
cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z");
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z");
|
||||
expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z");
|
||||
});
|
||||
|
||||
it("includes attachments when present", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
|
||||
const attachment1 = new RealAttachmentView();
|
||||
attachment1.id = "attachment-id-1";
|
||||
attachment1.fileName = "file1.txt";
|
||||
|
||||
const attachment2 = new RealAttachmentView();
|
||||
attachment2.id = "attachment-id-2";
|
||||
attachment2.fileName = "file2.pdf";
|
||||
|
||||
cipherView.attachments = [attachment1, attachment2];
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
expect(result.attachments).toHaveLength(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["Login", CipherType.Login, "login.view", "LoginView"],
|
||||
["Card", CipherType.Card, "card.view", "CardView"],
|
||||
["Identity", CipherType.Identity, "identity.view", "IdentityView"],
|
||||
["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"],
|
||||
["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"],
|
||||
])(
|
||||
"creates correct type property for %s cipher",
|
||||
(typeName: string, cipherType: CipherType, moduleName: string, className: string) => {
|
||||
const module = jest.requireActual(`./${moduleName}`);
|
||||
const ViewClass = module[className];
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602";
|
||||
cipherView.name = `Test ${typeName}`;
|
||||
cipherView.type = cipherType;
|
||||
|
||||
// Set the appropriate view property
|
||||
const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
(cipherView as any)[viewPropertyName] = new ViewClass();
|
||||
|
||||
const result = cipherView.toSdkUpdateCipherRequest();
|
||||
|
||||
const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1);
|
||||
expect(result.type).toHaveProperty(typeKey);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("getSdkCipherViewType", () => {
|
||||
it("returns login type for Login cipher", () => {
|
||||
const { LoginView: RealLoginView } = jest.requireActual("./login.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.login = new RealLoginView();
|
||||
cipherView.login.username = "testuser";
|
||||
cipherView.login.password = "testpass";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("login");
|
||||
expect((result as any).login).toMatchObject({
|
||||
username: "testuser",
|
||||
password: "testpass",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns card type for Card cipher", () => {
|
||||
const { CardView: RealCardView } = jest.requireActual("./card.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.Card;
|
||||
cipherView.card = new RealCardView();
|
||||
cipherView.card.cardholderName = "John Doe";
|
||||
cipherView.card.number = "4111111111111111";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("card");
|
||||
expect((result as any).card.cardholderName).toBe("John Doe");
|
||||
expect((result as any).card.number).toBe("4111111111111111");
|
||||
});
|
||||
|
||||
it("returns identity type for Identity cipher", () => {
|
||||
const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.Identity;
|
||||
cipherView.identity = new RealIdentityView();
|
||||
cipherView.identity.firstName = "John";
|
||||
cipherView.identity.lastName = "Doe";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("identity");
|
||||
expect((result as any).identity.firstName).toBe("John");
|
||||
expect((result as any).identity.lastName).toBe("Doe");
|
||||
});
|
||||
|
||||
it("returns secureNote type for SecureNote cipher", () => {
|
||||
const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.SecureNote;
|
||||
cipherView.secureNote = new RealSecureNoteView();
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("secureNote");
|
||||
});
|
||||
|
||||
it("returns sshKey type for SshKey cipher", () => {
|
||||
const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view");
|
||||
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = CipherType.SshKey;
|
||||
cipherView.sshKey = new RealSshKeyView();
|
||||
cipherView.sshKey.privateKey = "privateKeyData";
|
||||
cipherView.sshKey.publicKey = "publicKeyData";
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("sshKey");
|
||||
expect((result as any).sshKey.privateKey).toBe("privateKeyData");
|
||||
expect((result as any).sshKey.publicKey).toBe("publicKeyData");
|
||||
});
|
||||
|
||||
it("defaults to empty login for unknown cipher type", () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.type = 999 as CipherType;
|
||||
|
||||
const result = cipherView.getSdkCipherViewType();
|
||||
|
||||
expect(result).toHaveProperty("login");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { ItemView } from "@bitwarden/common/vault/models/view/item.view";
|
||||
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
|
||||
import {
|
||||
CipherCreateRequest,
|
||||
CipherEditRequest,
|
||||
CipherViewType,
|
||||
CipherView as SdkCipherView,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { View } from "../../../models/view/view";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
@@ -332,6 +337,85 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return cipherView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to an SDK CipherCreateRequest
|
||||
*
|
||||
* @returns {CipherCreateRequest} The SDK cipher create request object
|
||||
*/
|
||||
toSdkCreateCipherRequest(): CipherCreateRequest {
|
||||
const sdkCipherCreateRequest: CipherCreateRequest = {
|
||||
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
|
||||
collectionIds: this.collectionIds ? this.collectionIds.map((i) => asUuid(i)) : [],
|
||||
folderId: this.folderId ? asUuid(this.folderId) : undefined,
|
||||
name: this.name ?? "",
|
||||
notes: this.notes,
|
||||
favorite: this.favorite ?? false,
|
||||
reprompt: this.reprompt ?? CipherRepromptType.None,
|
||||
fields: this.fields?.map((f) => f.toSdkFieldView()),
|
||||
type: this.getSdkCipherViewType(),
|
||||
};
|
||||
|
||||
return sdkCipherCreateRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to an SDK CipherEditRequest
|
||||
*
|
||||
* @returns {CipherEditRequest} The SDK cipher edit request object
|
||||
*/
|
||||
toSdkUpdateCipherRequest(): CipherEditRequest {
|
||||
const sdkCipherEditRequest: CipherEditRequest = {
|
||||
id: asUuid(this.id),
|
||||
organizationId: this.organizationId ? asUuid(this.organizationId) : undefined,
|
||||
folderId: this.folderId ? asUuid(this.folderId) : undefined,
|
||||
name: this.name ?? "",
|
||||
notes: this.notes,
|
||||
favorite: this.favorite ?? false,
|
||||
reprompt: this.reprompt ?? CipherRepromptType.None,
|
||||
fields: this.fields?.map((f) => f.toSdkFieldView()),
|
||||
type: this.getSdkCipherViewType(),
|
||||
revisionDate: this.revisionDate?.toISOString(),
|
||||
archivedDate: this.archivedDate?.toISOString(),
|
||||
attachments: this.attachments?.map((a) => a.toSdkAttachmentView()),
|
||||
key: this.key?.toSdk(),
|
||||
};
|
||||
|
||||
return sdkCipherEditRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SDK CipherViewType object for the cipher.
|
||||
*
|
||||
* @returns {CipherViewType} The SDK CipherViewType for the cipher.t
|
||||
*/
|
||||
getSdkCipherViewType(): CipherViewType {
|
||||
let viewType: CipherViewType;
|
||||
switch (this.type) {
|
||||
case CipherType.Card:
|
||||
viewType = { card: this.card?.toSdkCardView() };
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
viewType = { identity: this.identity?.toSdkIdentityView() };
|
||||
break;
|
||||
case CipherType.Login:
|
||||
viewType = { login: this.login?.toSdkLoginView() };
|
||||
break;
|
||||
case CipherType.SecureNote:
|
||||
viewType = { secureNote: this.secureNote?.toSdkSecureNoteView() };
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
viewType = { sshKey: this.sshKey?.toSdkSshKeyView() };
|
||||
break;
|
||||
default:
|
||||
viewType = {
|
||||
// Default to empty login - should not be valid code path.
|
||||
login: new LoginView().toSdkLoginView(),
|
||||
};
|
||||
break;
|
||||
}
|
||||
return viewType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps CipherView to SdkCipherView
|
||||
*
|
||||
|
||||
246
libs/common/src/vault/services/cipher-sdk.service.spec.ts
Normal file
246
libs/common/src/vault/services/cipher-sdk.service.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
|
||||
import { DefaultCipherSdkService } from "./cipher-sdk.service";
|
||||
|
||||
describe("DefaultCipherSdkService", () => {
|
||||
const sdkService = mock<SdkService>();
|
||||
const logService = mock<LogService>();
|
||||
const userId = "test-user-id" as UserId;
|
||||
const cipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
|
||||
|
||||
let cipherSdkService: DefaultCipherSdkService;
|
||||
let mockSdkClient: any;
|
||||
let mockCiphersSdk: any;
|
||||
let mockAdminSdk: any;
|
||||
let mockVaultSdk: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock the SDK client chain for admin operations
|
||||
mockAdminSdk = {
|
||||
create: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
};
|
||||
mockCiphersSdk = {
|
||||
create: jest.fn(),
|
||||
edit: jest.fn(),
|
||||
admin: jest.fn().mockReturnValue(mockAdminSdk),
|
||||
};
|
||||
mockVaultSdk = {
|
||||
ciphers: jest.fn().mockReturnValue(mockCiphersSdk),
|
||||
};
|
||||
const mockSdkValue = {
|
||||
vault: jest.fn().mockReturnValue(mockVaultSdk),
|
||||
};
|
||||
mockSdkClient = {
|
||||
take: jest.fn().mockReturnValue({
|
||||
value: mockSdkValue,
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock sdkService to return the mock client
|
||||
sdkService.userClient$.mockReturnValue(of(mockSdkClient));
|
||||
|
||||
cipherSdkService = new DefaultCipherSdkService(sdkService, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("createWithServer()", () => {
|
||||
it("should create cipher using SDK when orgAdmin is false", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Test Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.createWithServer(cipherView, userId, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: cipherView.name,
|
||||
organizationId: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result?.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should create cipher using SDK admin API when orgAdmin is true", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Test Admin Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockAdminSdk.create.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.createWithServer(cipherView, userId, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: cipherView.name,
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result?.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to create cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
mockCiphersSdk.create.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to create cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWithServer()", () => {
|
||||
it("should update cipher using SDK when orgAdmin is false", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Updated Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, false);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.edit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: cipherView.name,
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should update cipher using SDK admin API when orgAdmin is true", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Updated Admin Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const originalCipherView = new CipherView();
|
||||
originalCipherView.id = cipherId;
|
||||
originalCipherView.name = "Original Cipher";
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.updateWithServer(
|
||||
cipherView,
|
||||
userId,
|
||||
originalCipherView,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.edit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: cipherView.name,
|
||||
}),
|
||||
originalCipherView.toSdkCipherView(),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should update cipher using SDK admin API without originalCipherView", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.id = cipherId;
|
||||
cipherView.type = CipherType.Login;
|
||||
cipherView.name = "Updated Admin Cipher";
|
||||
cipherView.organizationId = orgId;
|
||||
|
||||
const mockSdkCipherView = cipherView.toSdkCipherView();
|
||||
mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView);
|
||||
|
||||
const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, true);
|
||||
|
||||
expect(sdkService.userClient$).toHaveBeenCalledWith(userId);
|
||||
expect(mockVaultSdk.ciphers).toHaveBeenCalled();
|
||||
expect(mockCiphersSdk.admin).toHaveBeenCalled();
|
||||
expect(mockAdminSdk.edit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
name: cipherView.name,
|
||||
}),
|
||||
expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called
|
||||
);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
expect(result.name).toBe(cipherView.name);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK client is not available", async () => {
|
||||
sdkService.userClient$.mockReturnValue(of(null));
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
await expect(
|
||||
cipherSdkService.updateWithServer(cipherView, userId, undefined, false),
|
||||
).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to update cipher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error and log when SDK throws an error", async () => {
|
||||
const cipherView = new CipherView();
|
||||
cipherView.name = "Test Cipher";
|
||||
|
||||
mockCiphersSdk.edit.mockRejectedValue(new Error("SDK error"));
|
||||
|
||||
await expect(
|
||||
cipherSdkService.updateWithServer(cipherView, userId, undefined, false),
|
||||
).rejects.toThrow();
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to update cipher"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
82
libs/common/src/vault/services/cipher-sdk.service.ts
Normal file
82
libs/common/src/vault/services/cipher-sdk.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { firstValueFrom, switchMap, catchError } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
|
||||
|
||||
export class DefaultCipherSdkService implements CipherSdkService {
|
||||
constructor(
|
||||
private sdkService: SdkService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async createWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
const sdkCreateRequest = cipherView.toSdkCreateCipherRequest();
|
||||
let result: SdkCipherView;
|
||||
if (orgAdmin) {
|
||||
result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest);
|
||||
} else {
|
||||
result = await ref.value.vault().ciphers().create(sdkCreateRequest);
|
||||
}
|
||||
return CipherView.fromSdkCipherView(result);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to create cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWithServer(
|
||||
cipher: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | undefined> {
|
||||
return await firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
switchMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest();
|
||||
let result: SdkCipherView;
|
||||
if (orgAdmin) {
|
||||
result = await ref.value
|
||||
.vault()
|
||||
.ciphers()
|
||||
.admin()
|
||||
.edit(
|
||||
sdkUpdateRequest,
|
||||
originalCipherView?.toSdkCipherView() || new CipherView().toSdkCipherView(),
|
||||
);
|
||||
} else {
|
||||
result = await ref.value.vault().ciphers().edit(sdkUpdateRequest);
|
||||
}
|
||||
return CipherView.fromSdkCipherView(result);
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to update cipher: ${error}`);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service";
|
||||
import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid";
|
||||
import { CipherKey, OrgKey, UserKey } from "../../types/key";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
|
||||
import { EncryptionContext } from "../abstractions/cipher.service";
|
||||
import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service";
|
||||
import { SearchService } from "../abstractions/search.service";
|
||||
@@ -54,9 +55,9 @@ function encryptText(clearText: string | Uint8Array) {
|
||||
const ENCRYPTED_BYTES = mock<EncArrayBuffer>();
|
||||
|
||||
const cipherData: CipherData = {
|
||||
id: "id",
|
||||
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId,
|
||||
folderId: "folderId",
|
||||
id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId,
|
||||
organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId,
|
||||
folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23",
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
organizationUseTotp: true,
|
||||
@@ -109,9 +110,10 @@ describe("Cipher Service", () => {
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
const cipherEncryptionService = mock<CipherEncryptionService>();
|
||||
const messageSender = mock<MessageSender>();
|
||||
const cipherSdkService = mock<CipherSdkService>();
|
||||
|
||||
const userId = "TestUserId" as UserId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId;
|
||||
const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId;
|
||||
|
||||
let cipherService: CipherService;
|
||||
let encryptionContext: EncryptionContext;
|
||||
@@ -145,6 +147,7 @@ describe("Cipher Service", () => {
|
||||
logService,
|
||||
cipherEncryptionService,
|
||||
messageSender,
|
||||
cipherSdkService,
|
||||
);
|
||||
|
||||
encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId };
|
||||
@@ -207,11 +210,22 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
describe("createWithServer()", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
|
||||
jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
|
||||
return new CipherView(cipher);
|
||||
});
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext, true);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId, true);
|
||||
const expectedObj = new CipherCreateRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -219,11 +233,15 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.organizationId = null!;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext, true);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId, true);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -231,11 +249,15 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipherCreate if collectionsIds != null", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.collectionIds = ["123"];
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipherCreate")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherCreateRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -243,35 +265,86 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
const spy = jest
|
||||
.spyOn(apiService, "postCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.createWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.createWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(expectedObj);
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
const expectedResult = new CipherView(encryptionContext.cipher);
|
||||
|
||||
const cipherSdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "createWithServer")
|
||||
.mockResolvedValue(expectedResult);
|
||||
|
||||
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
|
||||
const apiSpy = jest.spyOn(apiService, "postCipher");
|
||||
|
||||
const result = await cipherService.createWithServer(cipherView, userId);
|
||||
|
||||
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined);
|
||||
expect(apiSpy).not.toHaveBeenCalled();
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWithServer()", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext);
|
||||
jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => {
|
||||
return new CipherView(cipher);
|
||||
});
|
||||
jest.spyOn(cipherService, "upsert").mockResolvedValue({
|
||||
[cipherData.id as CipherId]: cipherData,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
testCipher.organizationId = orgId;
|
||||
const testContext = { cipher: testCipher, encryptedFor: userId };
|
||||
jest.spyOn(cipherService, "encrypt").mockResolvedValue(testContext);
|
||||
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipherAdmin")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext, true);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
.mockImplementation(() => Promise.resolve<any>(testCipher.toCipherData()));
|
||||
const cipherView = new CipherView(testCipher);
|
||||
await cipherService.updateWithServer(cipherView, userId, undefined, true);
|
||||
const expectedObj = new CipherRequest(testContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
expect(spy).toHaveBeenCalledWith(testCipher.id, expectedObj);
|
||||
});
|
||||
|
||||
it("should call apiService.putCipher if cipher.edit is true", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.edit = true;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.updateWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherRequest(encryptionContext);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -279,16 +352,79 @@ describe("Cipher Service", () => {
|
||||
});
|
||||
|
||||
it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(false);
|
||||
encryptionContext.cipher.edit = false;
|
||||
const spy = jest
|
||||
.spyOn(apiService, "putPartialCipher")
|
||||
.mockImplementation(() => Promise.resolve<any>(encryptionContext.cipher.toCipherData()));
|
||||
await cipherService.updateWithServer(encryptionContext);
|
||||
const cipherView = new CipherView(encryptionContext.cipher);
|
||||
await cipherService.updateWithServer(cipherView, userId);
|
||||
const expectedObj = new CipherPartialRequest(encryptionContext.cipher);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj);
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
const expectedResult = new CipherView(testCipher);
|
||||
|
||||
const cipherSdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "updateWithServer")
|
||||
.mockResolvedValue(expectedResult);
|
||||
|
||||
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
|
||||
const apiSpy = jest.spyOn(apiService, "putCipher");
|
||||
|
||||
const result = await cipherService.updateWithServer(cipherView, userId);
|
||||
|
||||
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined);
|
||||
expect(apiSpy).not.toHaveBeenCalled();
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
});
|
||||
|
||||
it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => {
|
||||
configService.getFeatureFlag
|
||||
.calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const testCipher = new Cipher(cipherData);
|
||||
const cipherView = new CipherView(testCipher);
|
||||
const originalCipherView = new CipherView(testCipher);
|
||||
const expectedResult = new CipherView(testCipher);
|
||||
|
||||
const cipherSdkServiceSpy = jest
|
||||
.spyOn(cipherSdkService, "updateWithServer")
|
||||
.mockResolvedValue(expectedResult);
|
||||
|
||||
const clearCacheSpy = jest.spyOn(cipherService, "clearCache");
|
||||
const apiSpy = jest.spyOn(apiService, "putCipherAdmin");
|
||||
|
||||
const result = await cipherService.updateWithServer(
|
||||
cipherView,
|
||||
userId,
|
||||
originalCipherView,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(cipherSdkServiceSpy).toHaveBeenCalledWith(
|
||||
cipherView,
|
||||
userId,
|
||||
originalCipherView,
|
||||
true,
|
||||
);
|
||||
expect(apiSpy).not.toHaveBeenCalled();
|
||||
expect(clearCacheSpy).toHaveBeenCalledWith(userId);
|
||||
expect(result).toBeInstanceOf(CipherView);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid
|
||||
import { OrgKey, UserKey } from "../../types/key";
|
||||
import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities";
|
||||
import { CipherEncryptionService } from "../abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "../abstractions/cipher-sdk.service";
|
||||
import {
|
||||
CipherService as CipherServiceAbstraction,
|
||||
EncryptionContext,
|
||||
@@ -120,6 +121,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
private logService: LogService,
|
||||
private cipherEncryptionService: CipherEncryptionService,
|
||||
private messageSender: MessageSender,
|
||||
private cipherSdkService: CipherSdkService,
|
||||
) {}
|
||||
|
||||
localData$(userId: UserId): Observable<Record<CipherId, LocalData>> {
|
||||
@@ -903,6 +905,40 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
async createWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
|
||||
if (useSdk) {
|
||||
return (
|
||||
(await this.createWithServerUsingSdk(cipherView, userId, orgAdmin)) || new CipherView()
|
||||
);
|
||||
}
|
||||
|
||||
const encrypted = await this.encrypt(cipherView, userId);
|
||||
const result = await this.createWithServer_legacy(encrypted, orgAdmin);
|
||||
return await this.decrypt(result, userId);
|
||||
}
|
||||
|
||||
private async createWithServerUsingSdk(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView | void> {
|
||||
const resultCipherView = await this.cipherSdkService.createWithServer(
|
||||
cipherView,
|
||||
userId,
|
||||
orgAdmin,
|
||||
);
|
||||
await this.clearCache(userId);
|
||||
return resultCipherView;
|
||||
}
|
||||
|
||||
private async createWithServer_legacy(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher> {
|
||||
@@ -929,6 +965,42 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
|
||||
async updateWithServer(
|
||||
cipherView: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const useSdk = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27632_SdkCipherCrudOperations,
|
||||
);
|
||||
|
||||
if (useSdk) {
|
||||
return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin);
|
||||
}
|
||||
|
||||
const encrypted = await this.encrypt(cipherView, userId);
|
||||
const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin);
|
||||
const updatedCipherView = await this.decrypt(updatedCipher, userId);
|
||||
return updatedCipherView;
|
||||
}
|
||||
|
||||
async updateWithServerUsingSdk(
|
||||
cipher: CipherView,
|
||||
userId: UserId,
|
||||
originalCipherView?: CipherView,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<CipherView> {
|
||||
const resultCipherView = await this.cipherSdkService.updateWithServer(
|
||||
cipher,
|
||||
userId,
|
||||
originalCipherView,
|
||||
orgAdmin,
|
||||
);
|
||||
await this.clearCache(userId);
|
||||
return resultCipherView;
|
||||
}
|
||||
|
||||
async updateWithServer_legacy(
|
||||
{ cipher, encryptedFor }: EncryptionContext,
|
||||
orgAdmin?: boolean,
|
||||
): Promise<Cipher> {
|
||||
@@ -1119,8 +1191,7 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
//in order to keep item and it's attachments with the same encryption level
|
||||
if (cipher.key != null && !cipherKeyEncryptionEnabled) {
|
||||
const model = await this.decrypt(cipher, userId);
|
||||
const reEncrypted = await this.encrypt(model, userId);
|
||||
await this.updateWithServer(reEncrypted);
|
||||
await this.updateWithServer(model, userId);
|
||||
}
|
||||
|
||||
const encFileName = await this.encryptService.encryptString(filename, cipherEncKey);
|
||||
|
||||
@@ -30,21 +30,14 @@
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
|
||||
>
|
||||
@if (sideNavService.open()) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Directive, EventEmitter, Output, input, model } from "@angular/core";
|
||||
import { Directive, output, input, model } from "@angular/core";
|
||||
import { RouterLink, RouterLinkActive } from "@angular/router";
|
||||
|
||||
/**
|
||||
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`.
|
||||
* Base class for navigation components in the side navigation.
|
||||
*
|
||||
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties
|
||||
* that are passed down to `NavItemComponent`.
|
||||
*/
|
||||
@Directive()
|
||||
export abstract class NavBaseComponent {
|
||||
@@ -38,23 +41,26 @@ export abstract class NavBaseComponent {
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* @remarks
|
||||
* We can't name this "routerLink" because Angular will mount the `RouterLink` directive.
|
||||
*
|
||||
* See: {@link https://github.com/angular/angular/issues/24482}
|
||||
* @see {@link RouterLink.routerLink}
|
||||
* @see {@link https://github.com/angular/angular/issues/24482}
|
||||
*/
|
||||
readonly route = input<RouterLink["routerLink"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLink.relativeTo}
|
||||
* @see {@link RouterLink.relativeTo}
|
||||
*/
|
||||
readonly relativeTo = input<RouterLink["relativeTo"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
* @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" }
|
||||
* @see {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
*/
|
||||
readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
|
||||
paths: "subset",
|
||||
@@ -71,7 +77,5 @@ export abstract class NavBaseComponent {
|
||||
/**
|
||||
* Fires when main content is clicked
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
|
||||
readonly mainContentClicked = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@if (sideNavService.open$ | async) {
|
||||
@if (sideNavService.open()) {
|
||||
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
||||
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* A visual divider for separating navigation items in the side navigation.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-nav-divider",
|
||||
templateUrl: "./nav-divider.component.html",
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavDividerComponent {
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="tw-ms-auto tw-text-fg-sidenav-text"
|
||||
[ngClass]="{
|
||||
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
|
||||
}"
|
||||
[class]="variantValue === 'tree' && !open() ? 'tw-transform tw-rotate-[90deg]' : ''"
|
||||
[bitIconButton]="toggleButtonIcon()"
|
||||
buttonType="nav-contrast"
|
||||
(click)="toggle($event)"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
booleanAttribute,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Optional,
|
||||
Output,
|
||||
SkipSelf,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
contentChildren,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -22,8 +19,6 @@ import { NavBaseComponent } from "./nav-base.component";
|
||||
import { NavGroupAbstraction, NavItemComponent } from "./nav-item.component";
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// 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: "bit-nav-group",
|
||||
templateUrl: "./nav-group.component.html",
|
||||
@@ -31,20 +26,24 @@ import { SideNavService } from "./side-nav.service";
|
||||
{ provide: NavBaseComponent, useExisting: NavGroupComponent },
|
||||
{ provide: NavGroupAbstraction, useExisting: NavGroupComponent },
|
||||
],
|
||||
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
imports: [NgTemplateOutlet, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavGroupComponent extends NavBaseComponent {
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
private readonly parentNavGroup = inject(NavGroupComponent, { optional: true, skipSelf: true });
|
||||
|
||||
// Query direct children for hideIfEmpty functionality
|
||||
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false });
|
||||
|
||||
readonly sideNavOpen = toSignal(this.sideNavService.open$);
|
||||
protected readonly sideNavOpen = this.sideNavService.open;
|
||||
|
||||
readonly sideNavAndGroupOpen = computed(() => {
|
||||
return this.open() && this.sideNavOpen();
|
||||
});
|
||||
|
||||
/** When the side nav is open, the parent nav item should not show active styles when open. */
|
||||
readonly parentHideActiveStyles = computed(() => {
|
||||
protected readonly parentHideActiveStyles = computed(() => {
|
||||
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
|
||||
});
|
||||
|
||||
@@ -80,7 +79,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
/**
|
||||
* UID for `[attr.aria-controls]`
|
||||
*/
|
||||
protected contentId = Math.random().toString(36).substring(2);
|
||||
protected readonly contentId = Math.random().toString(36).substring(2);
|
||||
|
||||
/**
|
||||
* Is `true` if the expanded content is visible
|
||||
@@ -98,15 +97,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
/** Does not toggle the expanded state on click */
|
||||
readonly disableToggleOnClick = input(false, { transform: booleanAttribute });
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output()
|
||||
openChange = new EventEmitter<boolean>();
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
|
||||
) {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Set tree depth based on parent's depth
|
||||
@@ -118,9 +109,8 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
|
||||
setOpen(isOpen: boolean) {
|
||||
this.open.set(isOpen);
|
||||
this.openChange.emit(this.open());
|
||||
if (this.open()) {
|
||||
this.parentNavGroup?.setOpen(this.open());
|
||||
if (this.open() && this.parentNavGroup) {
|
||||
this.parentNavGroup.setOpen(this.open());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +120,9 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
}
|
||||
|
||||
protected handleMainContentClicked() {
|
||||
if (!this.sideNavService.open) {
|
||||
if (!this.sideNavService.open()) {
|
||||
if (!this.route()) {
|
||||
this.sideNavService.setOpen();
|
||||
this.sideNavService.open.set(true);
|
||||
}
|
||||
this.open.set(true);
|
||||
} else if (!this.disableToggleOnClick()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, importProvidersFrom } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
|
||||
|
||||
@@ -14,10 +14,9 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
|
||||
import { NavGroupComponent } from "./nav-group.component";
|
||||
import { NavigationModule } from "./navigation.module";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class DummyContentComponent {}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
@let open = sideNavService.open$ | async;
|
||||
@let open = sideNavService.open();
|
||||
@if (open || icon()) {
|
||||
<div
|
||||
[style.padding-inline-start]="navItemIndentationPadding()"
|
||||
class="tw-relative tw-rounded-md tw-h-10"
|
||||
[class]="fvwStyles()"
|
||||
[class.tw-bg-bg-sidenav-active-item]="showActiveStyles"
|
||||
[class.tw-bg-bg-sidenav-background]="!showActiveStyles"
|
||||
[class.hover:tw-bg-bg-sidenav-item-hover]="!showActiveStyles"
|
||||
[class]="fvwStyles$ | async"
|
||||
>
|
||||
<div class="tw-relative tw-flex tw-items-center tw-h-full">
|
||||
@if (open) {
|
||||
@@ -17,35 +17,9 @@
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
</div>
|
||||
}
|
||||
<ng-container *ngIf="route(); then isAnchor; else isButton"></ng-container>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text {{ icon() }}"
|
||||
[attr.aria-hidden]="open"
|
||||
[attr.aria-label]="text()"
|
||||
></i>
|
||||
}
|
||||
@if (open) {
|
||||
<span class="tw-truncate">{{ text() }}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if a value was passed to `this.route` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
@if (route()) {
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin` -->
|
||||
<!-- The following `class` field should match the button class field below -->
|
||||
<a
|
||||
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
@@ -61,11 +35,8 @@
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.route` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
} @else {
|
||||
<!-- Class field should match anchor class field above -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
@@ -75,7 +46,7 @@
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</button>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
@if (open) {
|
||||
<div
|
||||
@@ -88,3 +59,27 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text"
|
||||
[class]="icon()"
|
||||
[attr.aria-hidden]="open"
|
||||
[attr.aria-label]="text()"
|
||||
></i>
|
||||
}
|
||||
@if (open) {
|
||||
<span class="tw-truncate">{{ text() }}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostListener, Optional, computed, input, model } from "@angular/core";
|
||||
import { RouterLinkActive, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
import { RouterModule, RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
|
||||
@@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction {
|
||||
abstract treeDepth: ReturnType<typeof model<number>>;
|
||||
}
|
||||
|
||||
// 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: "bit-nav-item",
|
||||
templateUrl: "./nav-item.component.html",
|
||||
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
|
||||
imports: [CommonModule, IconButtonModule, RouterModule],
|
||||
imports: [NgTemplateOutlet, IconButtonModule, RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
"(focusin)": "onFocusIn($event.target)",
|
||||
"(focusout)": "onFocusOut()",
|
||||
},
|
||||
})
|
||||
export class NavItemComponent extends NavBaseComponent {
|
||||
/**
|
||||
@@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
*/
|
||||
protected readonly TREE_DEPTH_PADDING = 1.75;
|
||||
|
||||
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
|
||||
/**
|
||||
* Forces active styles to be shown, regardless of the `routerLinkActiveOptions`
|
||||
*/
|
||||
readonly forceActiveStyles = input<boolean>(false);
|
||||
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true });
|
||||
|
||||
/**
|
||||
* Is `true` if `to` matches the current route
|
||||
*/
|
||||
@@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
* adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels
|
||||
*/
|
||||
protected readonly navItemIndentationPadding = computed(() => {
|
||||
const open = this.sideNavService.open;
|
||||
const open = this.sideNavService.open();
|
||||
const depth = this.treeDepth() ?? 0;
|
||||
|
||||
if (open && this.variant() === "tree") {
|
||||
@@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
* (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some
|
||||
* styles, so the entire component can have an outline.
|
||||
*/
|
||||
protected focusVisibleWithin$ = new BehaviorSubject(false);
|
||||
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
|
||||
map((value) =>
|
||||
value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" : "",
|
||||
),
|
||||
protected readonly focusVisibleWithin = signal(false);
|
||||
protected readonly fvwStyles = computed(() =>
|
||||
this.focusVisibleWithin()
|
||||
? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus"
|
||||
: "",
|
||||
);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.focusVisibleWithin$.next(false);
|
||||
|
||||
protected onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible"));
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() private parentNavGroup: NavGroupAbstraction,
|
||||
) {
|
||||
protected onFocusOut() {
|
||||
this.focusVisibleWithin.set(false);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Set tree depth based on parent's depth
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-sticky tw-top-0 tw-z-50 tw-pb-4': sideNavService.open,
|
||||
'tw-pb-[calc(theme(spacing.8)_+_2px)]': !sideNavService.open,
|
||||
}"
|
||||
class="tw-px-2 tw-pt-2"
|
||||
[class]="
|
||||
sideNavService.open()
|
||||
? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4'
|
||||
: 'tw-pb-[calc(theme(spacing.8)_+_2px)]'
|
||||
"
|
||||
>
|
||||
<!-- absolutely position the link svg to avoid shifting layout when sidenav is closed -->
|
||||
<a
|
||||
[routerLink]="route()"
|
||||
class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-bg-sidenav tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-border-focus hover:tw-bg-bg-sidenav-item-hover tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]"
|
||||
[ngClass]="{
|
||||
'!tw-h-[55px] [&_svg]:!tw-w-[26px]': !sideNavService.open,
|
||||
}"
|
||||
[class]="!sideNavService.open() ? '!tw-h-[55px] [&_svg]:!tw-w-[26px]' : ''"
|
||||
[attr.aria-label]="label()"
|
||||
[title]="label()"
|
||||
routerLinkActive
|
||||
ariaCurrentWhenActive="page"
|
||||
>
|
||||
<bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
|
||||
<bit-icon [icon]="sideNavService.open() ? openIcon() : closedIcon()"></bit-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core";
|
||||
import { RouterLinkActive, RouterLink } from "@angular/router";
|
||||
|
||||
import { BitwardenShield, Icon } from "@bitwarden/assets/svg";
|
||||
@@ -8,18 +7,25 @@ import { BitIconComponent } from "../icon/icon.component";
|
||||
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// 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: "bit-nav-logo",
|
||||
templateUrl: "./nav-logo.component.html",
|
||||
imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent],
|
||||
imports: [RouterLinkActive, RouterLink, BitIconComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavLogoComponent {
|
||||
/** Icon that is displayed when the side nav is closed */
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
/**
|
||||
* Icon that is displayed when the side nav is closed
|
||||
*
|
||||
* @default BitwardenShield
|
||||
*/
|
||||
readonly closedIcon = input(BitwardenShield);
|
||||
|
||||
/** Icon that is displayed when the side nav is open */
|
||||
/**
|
||||
* Icon that is displayed when the side nav is open
|
||||
*/
|
||||
readonly openIcon = input.required<Icon>();
|
||||
|
||||
/**
|
||||
@@ -27,8 +33,8 @@ export class NavLogoComponent {
|
||||
*/
|
||||
readonly route = input.required<string | any[]>();
|
||||
|
||||
/** Passed to `attr.aria-label` and `attr.title` */
|
||||
/**
|
||||
* Passed to `attr.aria-label` and `attr.title`
|
||||
*/
|
||||
readonly label = input.required<string>();
|
||||
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,60 @@
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
isOverlay: sideNavService.isOverlay$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
|
||||
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
|
||||
[ngStyle]="
|
||||
variant() === 'secondary' && {
|
||||
'--color-sidenav-text': 'var(--color-admin-sidenav-text)',
|
||||
'--color-sidenav-background': 'var(--color-admin-sidenav-background)',
|
||||
'--color-sidenav-active-item': 'var(--color-admin-sidenav-active-item)',
|
||||
'--color-sidenav-item-hover': 'var(--color-admin-sidenav-item-hover)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@let open = sideNavService.open();
|
||||
@let isOverlay = sideNavService.isOverlay();
|
||||
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
|
||||
[style.width.rem]="open ? (sideNavService.width$ | async) : undefined"
|
||||
[style]="
|
||||
variant() === 'secondary'
|
||||
? '--color-sidenav-text: var(--color-admin-sidenav-text); --color-sidenav-background: var(--color-admin-sidenav-background); --color-sidenav-active-item: var(--color-admin-sidenav-active-item); --color-sidenav-item-hover: var(--color-admin-sidenav-item-hover);'
|
||||
: ''
|
||||
"
|
||||
[cdkTrapFocus]="isOverlay"
|
||||
[attr.role]="isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!data.open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, inject, input, viewChild } from "@angular/core";
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
viewChild,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service";
|
||||
|
||||
export type SideNavVariant = "primary" | "secondary";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* Side navigation component that provides a collapsible navigation menu.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-side-nav",
|
||||
templateUrl: "side-nav.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
CdkTrapFocus,
|
||||
NavDividerComponent,
|
||||
BitIconButtonComponent,
|
||||
I18nPipe,
|
||||
DragDropModule,
|
||||
AsyncPipe,
|
||||
],
|
||||
host: {
|
||||
class: "tw-block tw-h-full",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SideNavComponent {
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
/**
|
||||
* Visual variant of the side navigation
|
||||
*
|
||||
* @default "primary"
|
||||
*/
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
|
||||
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
|
||||
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
protected handleKeyDown = (event: KeyboardEvent) => {
|
||||
protected readonly handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
this.sideNavService.setClose();
|
||||
this.sideNavService.open.set(false);
|
||||
this.toggleButton()?.nativeElement.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
map,
|
||||
startWith,
|
||||
debounceTime,
|
||||
first,
|
||||
} from "rxjs";
|
||||
import { computed, effect, inject, Injectable, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs";
|
||||
|
||||
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
|
||||
|
||||
@@ -32,16 +23,17 @@ export class SideNavService {
|
||||
|
||||
private rootFontSizePx: number;
|
||||
|
||||
private _open$ = new BehaviorSubject<boolean>(isAtOrLargerThanBreakpoint("md"));
|
||||
open$ = this._open$.asObservable();
|
||||
/**
|
||||
* Whether the side navigation is open or closed.
|
||||
*/
|
||||
readonly open = signal(isAtOrLargerThanBreakpoint("md"));
|
||||
|
||||
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
|
||||
private _userCollapsePreference$ = new BehaviorSubject<CollapsePreference>(null);
|
||||
userCollapsePreference$ = this._userCollapsePreference$.asObservable();
|
||||
readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true });
|
||||
|
||||
isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe(
|
||||
map(([open, isLargeScreen]) => open && !isLargeScreen),
|
||||
);
|
||||
readonly userCollapsePreference = signal<CollapsePreference>(null);
|
||||
|
||||
readonly isOverlay = computed(() => this.open() && !this.isLargeScreen());
|
||||
|
||||
/**
|
||||
* Local component state width
|
||||
@@ -67,16 +59,14 @@ export class SideNavService {
|
||||
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
|
||||
|
||||
// Handle open/close state
|
||||
combineLatest([this.isLargeScreen$, this.userCollapsePreference$])
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(([isLargeScreen, userCollapsePreference]) => {
|
||||
if (!isLargeScreen) {
|
||||
this.setClose();
|
||||
} else if (userCollapsePreference !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.setOpen();
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (!this.isLargeScreen()) {
|
||||
this.open.set(false);
|
||||
} else if (this.userCollapsePreference() !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.open.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the resizable width from state provider
|
||||
this.widthState$.pipe(first()).subscribe((width: number) => {
|
||||
@@ -89,31 +79,14 @@ export class SideNavService {
|
||||
});
|
||||
}
|
||||
|
||||
get open() {
|
||||
return this._open$.getValue();
|
||||
}
|
||||
|
||||
setOpen() {
|
||||
this._open$.next(true);
|
||||
}
|
||||
|
||||
setClose() {
|
||||
this._open$.next(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the open/close state of the side nav
|
||||
*/
|
||||
toggle() {
|
||||
const curr = this._open$.getValue();
|
||||
// Store user's preference based on what state they're toggling TO
|
||||
this._userCollapsePreference$.next(curr ? "closed" : "open");
|
||||
this.userCollapsePreference.set(this.open() ? "closed" : "open");
|
||||
|
||||
if (curr) {
|
||||
this.setClose();
|
||||
} else {
|
||||
this.setOpen();
|
||||
}
|
||||
this.open.set(!this.open());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,14 +37,13 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
|
||||
// Creating a new cipher
|
||||
if (cipher.id == null || cipher.id === "") {
|
||||
const encrypted = await this.cipherService.encrypt(cipher, activeUserId);
|
||||
savedCipher = await this.cipherService.createWithServer(encrypted, config.admin);
|
||||
return await this.cipherService.decrypt(savedCipher, activeUserId);
|
||||
return await this.cipherService.createWithServer(cipher, activeUserId, config.admin);
|
||||
}
|
||||
|
||||
if (config.originalCipher == null) {
|
||||
throw new Error("Original cipher is required for updating an existing cipher");
|
||||
}
|
||||
const originalCipherView = await this.decryptCipher(config.originalCipher);
|
||||
|
||||
// Updating an existing cipher
|
||||
|
||||
@@ -66,35 +65,31 @@ export class DefaultCipherFormService implements CipherFormService {
|
||||
);
|
||||
// If the collectionIds are the same, update the cipher normally
|
||||
} else if (isSetEqual(originalCollectionIds, newCollectionIds)) {
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
const savedCipherView = await this.cipherService.updateWithServer(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher,
|
||||
originalCipherView,
|
||||
config.admin,
|
||||
);
|
||||
savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin);
|
||||
savedCipher = await this.cipherService
|
||||
.encrypt(savedCipherView, activeUserId)
|
||||
.then((res) => res.cipher);
|
||||
} else {
|
||||
const encrypted = await this.cipherService.encrypt(
|
||||
cipher,
|
||||
activeUserId,
|
||||
null,
|
||||
null,
|
||||
config.originalCipher,
|
||||
);
|
||||
const encryptedCipher = encrypted.cipher;
|
||||
|
||||
// Updating a cipher with collection changes is not supported with a single request currently
|
||||
// First update the cipher with the original collectionIds
|
||||
encryptedCipher.collectionIds = config.originalCipher.collectionIds;
|
||||
await this.cipherService.updateWithServer(
|
||||
encrypted,
|
||||
cipher.collectionIds = config.originalCipher.collectionIds;
|
||||
const newCipher = await this.cipherService.updateWithServer(
|
||||
cipher,
|
||||
activeUserId,
|
||||
originalCipherView,
|
||||
config.admin || originalCollectionIds.size === 0,
|
||||
);
|
||||
|
||||
// Then save the new collection changes separately
|
||||
encryptedCipher.collectionIds = cipher.collectionIds;
|
||||
newCipher.collectionIds = cipher.collectionIds;
|
||||
|
||||
// TODO: Remove after migrating all SDK ops
|
||||
const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId);
|
||||
if (config.admin || originalCollectionIds.size === 0) {
|
||||
// When using an admin config or the cipher was unassigned, update collections as an admin
|
||||
savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -42,11 +41,9 @@ describe("CipherViewComponent", () => {
|
||||
let mockLogService: LogService;
|
||||
let mockCipherRiskService: CipherRiskService;
|
||||
let mockBillingAccountProfileStateService: BillingAccountProfileStateService;
|
||||
let mockConfigService: ConfigService;
|
||||
|
||||
// Mock data
|
||||
let mockCipherView: CipherView;
|
||||
let featureFlagEnabled$: BehaviorSubject<boolean>;
|
||||
let hasPremiumFromAnySource$: BehaviorSubject<boolean>;
|
||||
let activeAccount$: BehaviorSubject<Account>;
|
||||
|
||||
@@ -57,7 +54,6 @@ describe("CipherViewComponent", () => {
|
||||
email: "test@example.com",
|
||||
} as Account);
|
||||
|
||||
featureFlagEnabled$ = new BehaviorSubject(false);
|
||||
hasPremiumFromAnySource$ = new BehaviorSubject(true);
|
||||
|
||||
// Create service mocks
|
||||
@@ -83,9 +79,6 @@ describe("CipherViewComponent", () => {
|
||||
.fn()
|
||||
.mockReturnValue(hasPremiumFromAnySource$);
|
||||
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$);
|
||||
|
||||
// Setup mock cipher view
|
||||
mockCipherView = new CipherView();
|
||||
mockCipherView.id = "cipher-id";
|
||||
@@ -110,7 +103,6 @@ describe("CipherViewComponent", () => {
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
@@ -145,7 +137,6 @@ describe("CipherViewComponent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset observables to default values for this test suite
|
||||
featureFlagEnabled$.next(true);
|
||||
hasPremiumFromAnySource$.next(true);
|
||||
|
||||
// Setup default mock for computeCipherRiskForUser (individual tests can override)
|
||||
@@ -162,18 +153,6 @@ describe("CipherViewComponent", () => {
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("returns false when feature flag is disabled", fakeAsync(() => {
|
||||
featureFlagEnabled$.next(false);
|
||||
|
||||
const cipher = createLoginCipherView();
|
||||
fixture.componentRef.setInput("cipher", cipher);
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled();
|
||||
expect(component.passwordIsAtRisk()).toBe(false);
|
||||
}));
|
||||
|
||||
it("returns false when cipher has no login password", fakeAsync(() => {
|
||||
const cipher = createLoginCipherView();
|
||||
cipher.login = {} as any; // No password
|
||||
|
||||
@@ -13,8 +13,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { isCardExpired } from "@bitwarden/common/autofill/utils";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getByIds } from "@bitwarden/common/platform/misc";
|
||||
@@ -113,7 +111,6 @@ export class CipherViewComponent {
|
||||
private logService: LogService,
|
||||
private cipherRiskService: CipherRiskService,
|
||||
private billingAccountService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
readonly resolvedCollections = toSignal<CollectionView[] | undefined>(
|
||||
@@ -248,19 +245,9 @@ export class CipherViewComponent {
|
||||
* The password is only evaluated when the user is premium and has edit access to the cipher.
|
||||
*/
|
||||
readonly passwordIsAtRisk = toSignal(
|
||||
combineLatest([
|
||||
this.activeUserId$,
|
||||
this.cipher$,
|
||||
this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium),
|
||||
]).pipe(
|
||||
switchMap(([userId, cipher, featureEnabled]) => {
|
||||
if (
|
||||
!featureEnabled ||
|
||||
!cipher.hasLoginPassword ||
|
||||
!cipher.edit ||
|
||||
cipher.organizationId ||
|
||||
cipher.isDeleted
|
||||
) {
|
||||
combineLatest([this.activeUserId$, this.cipher$]).pipe(
|
||||
switchMap(([userId, cipher]) => {
|
||||
if (!cipher.hasLoginPassword || !cipher.edit || cipher.organizationId || cipher.isDeleted) {
|
||||
return of(false);
|
||||
}
|
||||
return this.switchPremium$(
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
buttonType="main"
|
||||
size="small"
|
||||
type="button"
|
||||
[label]="'downloadAttachmentName' | i18n: attachment().fileName"
|
||||
[label]="'downloadAttachmentLabel' | i18n"
|
||||
></button>
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => {
|
||||
it("renders delete button", () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css("button"));
|
||||
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName");
|
||||
expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel");
|
||||
});
|
||||
|
||||
describe("download attachment", () => {
|
||||
|
||||
67
package-lock.json
generated
67
package-lock.json
generated
@@ -52,7 +52,7 @@
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"ngx-toastr": "19.1.0",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-forge": "1.3.2",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
"open": "11.0.0",
|
||||
@@ -110,7 +110,7 @@
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/lunr": "2.3.7",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@types/node-forge": "1.3.14",
|
||||
"@types/papaparse": "5.5.0",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
@@ -192,11 +192,11 @@
|
||||
},
|
||||
"apps/browser": {
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.12.1"
|
||||
"version": "2026.1.0"
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@bitwarden/cli",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"dependencies": {
|
||||
"@koa/multer": "4.0.0",
|
||||
@@ -217,7 +217,7 @@
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-forge": "1.3.2",
|
||||
"open": "11.0.0",
|
||||
"papaparse": "5.5.3",
|
||||
@@ -278,7 +278,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@bitwarden/desktop",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0"
|
||||
},
|
||||
@@ -491,7 +491,7 @@
|
||||
},
|
||||
"apps/web": {
|
||||
"name": "@bitwarden/web-vault",
|
||||
"version": "2026.1.0"
|
||||
"version": "2026.1.1"
|
||||
},
|
||||
"libs/admin-console": {
|
||||
"name": "@bitwarden/admin-console",
|
||||
@@ -15842,53 +15842,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz",
|
||||
"integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==",
|
||||
"version": "2.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"form-data": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch/node_modules/form-data": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz",
|
||||
"integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-fetch/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"form-data": "^4.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
@@ -32816,9 +32777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz",
|
||||
"integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/lunr": "2.3.7",
|
||||
"@types/node": "22.19.3",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/node-fetch": "2.6.13",
|
||||
"@types/node-forge": "1.3.14",
|
||||
"@types/papaparse": "5.5.0",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
@@ -191,7 +191,7 @@
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"ngx-toastr": "19.1.0",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-forge": "1.3.2",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
"open": "11.0.0",
|
||||
|
||||
Reference in New Issue
Block a user