diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index edbc9d98cc9..224020991d1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,27 +9,3 @@ ## 📸 Screenshots - -## ⏰ Reminders before review - -- Contributor guidelines followed -- All formatters and local linters executed and passed -- Written new unit and / or integration tests where applicable -- Protected functional changes with optionality (feature flags) -- Used internationalization (i18n) for all UI strings -- CI builds passed -- Communicated to DevOps any deployment requirements -- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team - -## đŸĻŽ Reviewer guidelines - - - -- 👍 (`:+1:`) or similar for great changes -- 📝 (`:memo:`) or â„šī¸ (`:information_source:`) for notes or general info -- ❓ (`:question:`) for questions -- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion -- 🎨 (`:art:`) for suggestions / improvements -- ❌ (`:x:`) or âš ī¸ (`:warning:`) for more significant problems or concerns needing attention -- 🌱 (`:seedling:`) or â™ģī¸ (`:recycle:`) for future improvements or indications of technical debt -- ⛏ (`:pick:`) for minor or nitpick changes diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6b652149d8d..f7be45fb3a0 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1894,7 +1894,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.pull_request.head.sha }} @@ -1937,7 +1937,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.workflow_run.head_sha }} @@ -1978,7 +1978,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.workflow_run.head_sha }} @@ -2033,7 +2033,7 @@ jobs: - linux-arm64 steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.workflow_run.head_sha }} @@ -2086,7 +2086,7 @@ jobs: _CPU_ARCH: ${{ matrix.os == 'ubuntu-22.04' && 'amd64' || 'arm64' }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.workflow_run.head_sha }} @@ -2130,7 +2130,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.workflow_run.head_sha }} @@ -2174,7 +2174,7 @@ jobs: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Check out repo - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 ref: ${{ github.event.workflow_run.head_sha }} diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 86cdbffe059..4f55e68fb41 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Subject, switchMap, timer } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; @@ -25,7 +23,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private activeFormSubmissionRequests: ActiveFormSubmissionRequests = new Set(); private modifyLoginCipherFormData: ModifyLoginCipherFormDataForTab = new Map(); private clearLoginCipherFormDataSubject: Subject = new Subject(); - private notificationFallbackTimeout: number | NodeJS.Timeout | null; + private notificationFallbackTimeout: number | NodeJS.Timeout | null = null; private readonly formSubmissionRequestMethods: Set = new Set(["POST", "PUT", "PATCH"]); private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = { generatedPasswordFilled: ({ message, sender }) => @@ -63,7 +61,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg sender: chrome.runtime.MessageSender, ) { if (await this.shouldInitAddLoginOrChangePasswordNotification(message, sender)) { - this.websiteOriginsWithFields.set(sender.tab.id, this.getSenderUrlMatchPatterns(sender)); + const tabId = sender.tab?.id; + if (tabId === undefined) { + return; + } + this.websiteOriginsWithFields.set(tabId, this.getSenderUrlMatchPatterns(sender)); this.setupWebRequestsListeners(); } } @@ -80,11 +82,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) { + const tabId = sender.tab?.id; + if (tabId === undefined) { + return false; + } + return ( (await this.isAddLoginOrChangePasswordNotificationEnabled()) && !(await this.isSenderFromExcludedDomain(sender)) && - message.details?.fields?.length > 0 && - !this.websiteOriginsWithFields.has(sender.tab.id) + (message.details?.fields?.length ?? 0) > 0 && + !this.websiteOriginsWithFields.has(tabId) ); } @@ -107,8 +114,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { return new Set([ - ...generateDomainMatchPatterns(sender.url), - ...generateDomainMatchPatterns(sender.tab.url), + ...(sender.url ? generateDomainMatchPatterns(sender.url) : []), + ...(sender.tab?.url ? generateDomainMatchPatterns(sender.tab.url) : []), ]); } @@ -123,7 +130,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg message: OverlayNotificationsExtensionMessage, sender: chrome.runtime.MessageSender, ) => { - if (!this.websiteOriginsWithFields.has(sender.tab.id)) { + const tabId = sender.tab?.id; + if (tabId === undefined || !this.websiteOriginsWithFields.has(tabId)) { return; } @@ -135,25 +143,24 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.clearLoginCipherFormDataSubject.next(); const formData = { uri, username, password, newPassword }; - const existingModifyLoginData = this.modifyLoginCipherFormData.get(sender.tab.id); + const existingModifyLoginData = this.modifyLoginCipherFormData.get(tabId); if (existingModifyLoginData) { formData.username = formData.username || existingModifyLoginData.username; formData.password = formData.password || existingModifyLoginData.password; formData.newPassword = formData.newPassword || existingModifyLoginData.newPassword; } - this.modifyLoginCipherFormData.set(sender.tab.id, formData); + this.modifyLoginCipherFormData.set(tabId, formData); this.clearNotificationFallbackTimeout(); - this.notificationFallbackTimeout = setTimeout( - () => - this.setupNotificationInitTrigger( - sender.tab.id, - "", - this.modifyLoginCipherFormData.get(sender.tab.id), - ).catch((error) => this.logService.error(error)), - 1500, - ); + this.notificationFallbackTimeout = setTimeout(() => { + const modifyLoginData = this.modifyLoginCipherFormData.get(tabId); + if (modifyLoginData) { + this.setupNotificationInitTrigger(tabId, "", modifyLoginData).catch((error) => + this.logService.error(error), + ); + } + }, 1500); }; /** @@ -176,6 +183,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private async isSenderFromExcludedDomain(sender: chrome.runtime.MessageSender): Promise { try { const senderOrigin = sender.origin; + if (!senderOrigin) { + return false; + } + const serverConfig = await this.notificationBackground.getActiveUserServerConfig(); const activeUserVault = serverConfig?.environment?.vault; if (activeUserVault === senderOrigin) { @@ -232,11 +243,12 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg details: chrome.webRequest.OnBeforeRequestDetails, ): undefined => { if (this.isPostSubmissionFormRedirection(details)) { - this.setupNotificationInitTrigger( - details.tabId, - details.requestId, - this.modifyLoginCipherFormData.get(details.tabId), - ).catch((error) => this.logService.error(error)); + const modifyLoginData = this.modifyLoginCipherFormData.get(details.tabId); + if (modifyLoginData) { + this.setupNotificationInitTrigger(details.tabId, details.requestId, modifyLoginData).catch( + (error) => this.logService.error(error), + ); + } return; } @@ -385,6 +397,10 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.clearNotificationFallbackTimeout(); const tab = await BrowserApi.getTab(tabId); + if (!tab) { + return; + } + if (tab.status !== "complete") { await this.delayNotificationInitUntilTabIsComplete(tabId, requestId, modifyLoginData); return; @@ -410,7 +426,9 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const handleWebNavigationOnCompleted = async () => { chrome.webNavigation.onCompleted.removeListener(handleWebNavigationOnCompleted); const tab = await BrowserApi.getTab(tabId); - await this.processNotifications(requestId, modifyLoginData, tab); + if (tab) { + await this.processNotifications(requestId, modifyLoginData, tab); + } }; chrome.webNavigation.onCompleted.addListener(handleWebNavigationOnCompleted); }; diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index 61d6b9dc480..5c2b266f829 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -4,8 +4,10 @@ import { of } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AUTOFILL_ID, + COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATION_CODE_ID, @@ -85,6 +87,7 @@ describe("ContextMenuClickedHandler", () => { accountService = mockAccountServiceWith(mockUserId as UserId); totpService = mock(); eventCollectionService = mock(); + userVerificationService = mock(); sut = new ContextMenuClickedHandler( copyToClipboard, @@ -102,6 +105,93 @@ describe("ContextMenuClickedHandler", () => { afterEach(() => jest.resetAllMocks()); describe("run", () => { + beforeEach(() => { + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); + userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false); + }); + + const runWithUrl = (data: chrome.contextMenus.OnClickData) => + sut.run(data, { url: "https://test.com" } as any); + + describe("early returns", () => { + it.each([ + { + name: "tab id is missing", + data: createData(COPY_IDENTIFIER_ID), + tab: { url: "https://test.com" } as any, + expectNotCalled: () => expect(copyToClipboard).not.toHaveBeenCalled(), + }, + { + name: "tab url is missing", + data: createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), + tab: {} as any, + expectNotCalled: () => { + expect(cipherService.getAllDecryptedForUrl).not.toHaveBeenCalled(); + expect(copyToClipboard).not.toHaveBeenCalled(); + }, + }, + ])("returns early when $name", async ({ data, tab, expectNotCalled }) => { + await expect(sut.run(data, tab)).resolves.toBeUndefined(); + expectNotCalled(); + }); + }); + + describe("missing cipher", () => { + it.each([ + { + label: "AUTOFILL", + parentId: AUTOFILL_ID, + extra: () => expect(autofill).not.toHaveBeenCalled(), + }, + { label: "username", parentId: COPY_USERNAME_ID, extra: () => {} }, + { label: "password", parentId: COPY_PASSWORD_ID, extra: () => {} }, + { + label: "totp", + parentId: COPY_VERIFICATION_CODE_ID, + extra: () => expect(totpService.getCode$).not.toHaveBeenCalled(), + }, + ])("breaks silently when cipher is missing for $label", async ({ parentId, extra }) => { + cipherService.getAllDecrypted.mockResolvedValue([]); + + await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined(); + + expect(copyToClipboard).not.toHaveBeenCalled(); + extra(); + }); + }); + + describe("missing login properties", () => { + it.each([ + { + label: "username", + parentId: COPY_USERNAME_ID, + unset: (c: CipherView): void => (c.login.username = undefined), + }, + { + label: "password", + parentId: COPY_PASSWORD_ID, + unset: (c: CipherView): void => (c.login.password = undefined), + }, + { + label: "totp", + parentId: COPY_VERIFICATION_CODE_ID, + unset: (c: CipherView): void => (c.login.totp = undefined), + isTotp: true, + }, + ])("breaks silently when $label property is missing", async ({ parentId, unset, isTotp }) => { + const cipher = createCipher(); + unset(cipher); + cipherService.getAllDecrypted.mockResolvedValue([cipher]); + + await expect(runWithUrl(createData(`${parentId}_1`, parentId))).resolves.toBeUndefined(); + + expect(copyToClipboard).not.toHaveBeenCalled(); + if (isTotp) { + expect(totpService.getCode$).not.toHaveBeenCalled(); + } + }); + }); + it("can generate password", async () => { await sut.run(createData(GENERATE_PASSWORD_ID), { id: 5 } as any); diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 6f0979d4fd5..aa01ada0838 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { firstValueFrom } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -72,6 +70,10 @@ export class ContextMenuClickedHandler { await this.generatePasswordToClipboard(tab); break; case COPY_IDENTIFIER_ID: + if (!tab.id) { + return; + } + this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; default: @@ -120,6 +122,10 @@ export class ContextMenuClickedHandler { if (isCreateCipherAction) { // pass; defer to logic below } else if (menuItemId === NOOP_COMMAND_SUFFIX) { + if (!tab.url) { + return; + } + const additionalCiphersToGet = info.parentMenuItemId === AUTOFILL_IDENTITY_ID ? [CipherType.Identity] @@ -158,6 +164,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -176,6 +186,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.username) { + break; + } + this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: @@ -184,6 +198,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.password) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -205,6 +223,10 @@ export class ContextMenuClickedHandler { break; } + if (!cipher || !cipher.login?.totp) { + break; + } + if (await this.isPasswordRepromptRequired(cipher)) { await openVaultItemPasswordRepromptPopout(tab, { cipherId: cipher.id, @@ -240,9 +262,10 @@ export class ContextMenuClickedHandler { } private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { + const tabId = tab.id!; return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( - tab.id, + tabId, { command: "getClickedElement" }, { frameId: info.frameId }, (identifier: string) => { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index b9b41943b04..9d551ec2622 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1510,6 +1510,7 @@ export default class MainBackground { this.accountService, this.billingAccountProfileStateService, this.configService, + this.logService, this.organizationService, this.platformUtilsService, this.stateProvider, diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts index 30aa947092d..746f5a1f8f7 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -9,7 +9,66 @@ import { import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; import { LogService } from "@bitwarden/logging"; -import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service"; +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); + }; +}; describe("PhishingDataService", () => { let service: PhishingDataService; @@ -17,17 +76,30 @@ describe("PhishingDataService", () => { let taskSchedulerService: TaskSchedulerService; let logService: MockProxy; let platformUtilsService: MockProxy; - const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); + const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - const setMockState = (state: PhishingData) => { - stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state); + 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 fetchWebAddressesSpy: jest.SpyInstance; + let fetchAndCompressSpy: jest.SpyInstance; - beforeEach(() => { + 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(); apiService = mock(); logService = mock(); @@ -40,54 +112,75 @@ describe("PhishingDataService", () => { service = new PhishingDataService( apiService, taskSchedulerService, - stateProvider, + fakeGlobalStateProvider, logService, platformUtilsService, ); - fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); - fetchWebAddressesSpy = jest.spyOn(service as any, "fetchPhishingWebAddresses"); + fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress"); + + 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 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"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(await service.isPhishingWebAddress(QAurl)).toBe(true); + + sub.unsubscribe(); + }); }); 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", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); + const url = new URL("http://phish.com"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(true); }); it("should not detect a safe web address", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); }); it("should match against root web address", async () => { - setMockState({ - webAddresses: ["phish.com", "badguy.net"], - timestamp: Date.now(), - checksum: "abc123", - applicationVersion: "1.0.0", - }); + service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(true); }); it("should not error on empty state", async () => { - setMockState(undefined as any); + service["_webAddressesSet"] = null; const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); @@ -95,64 +188,142 @@ describe("PhishingDataService", () => { }); describe("getNextWebAddresses", () => { + beforeEach(() => { + jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); + jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + }); + it("refetches all web addresses if applicationVersion has changed", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], + const prev: PhishingDataMeta = { timestamp: Date.now() - 60000, checksum: "old", applicationVersion: "1.0.0", }; fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]); platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["d.com", "e.com"]); - expect(result!.checksum).toBe("new"); - expect(result!.applicationVersion).toBe("2.0.0"); + expect(result!.blob).toBe("compressed-blob"); + expect(result!.meta!.checksum).toBe("new"); + expect(result!.meta!.applicationVersion).toBe("2.0.0"); }); - it("only updates timestamp if checksum matches", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, + 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!.webAddresses).toEqual(prev.webAddresses); - expect(result!.checksum).toBe("abc"); - expect(result!.timestamp).not.toBe(prev.timestamp); + expect(result).toBeNull(); }); - it("patches daily domains if cache is fresh", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 60000, + 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"); - fetchWebAddressesSpy.mockResolvedValue(["b.com", "c.com"]); + fetchAndCompressSpy.mockResolvedValue("new-blob"); const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["a.com", "b.com", "c.com"]); - expect(result!.checksum).toBe("new"); + expect(result!.blob).toBe("new-blob"); + expect(result!.meta!.checksum).toBe("new"); + }); + }); + + describe("compression helpers", () => { + let restore: () => void; + + beforeEach(async () => { + restore = setupPhishingMocks("abc"); }); - it("fetches all domains if cache is old", async () => { - const prev: PhishingData = { - webAddresses: ["a.com"], - timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchWebAddressesSpy.mockResolvedValue(["d.com", "e.com"]); - const result = await service.getNextWebAddresses(prev); - expect(result!.webAddresses).toEqual(["d.com", "e.com"]); - expect(result!.checksum).toBe("new"); + afterEach(() => { + if (restore) { + restore(); + } + delete (Uint8Array as any).fromBase64; + jest.restoreAllMocks(); + }); + + describe("_compressString", () => { + it("compresses a string to base64", async () => { + const out = await service["_compressString"]("abc"); + expect(out).toBe("YWJj"); // base64 for 'abc' + }); + + 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); + }); + + 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); + }); + }); + 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]); + + const out = await service["_decompressString"]("ignored"); + expect(out).toBe("abc"); + }); + + it("decompresses using atob on error", async () => { + const base64 = btoa(encodeURIComponent("abc")); + const out = await service["_decompressString"](base64); + expect(out).toBe("abc"); + }); + }); + }); + + describe("_loadBlobToMemory", () => { + it("loads blob into memory set", async () => { + const prevBlob = "ignored-base64"; + fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob); + + jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net"); + + await service["_loadBlobToMemory"](); + const set = service["_webAddressesSet"] as Set; + expect(set).toBeDefined(); + expect(set.has("phish.com")).toBe(true); + expect(set.has("badguy.net")).toBe(true); }); }); }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index 4bc31f8ea60..85e91b06a6b 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -3,7 +3,6 @@ import { EMPTY, first, firstValueFrom, - map, share, startWith, Subject, @@ -20,11 +19,14 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; -export type PhishingData = { - webAddresses: string[]; - timestamp: number; +/** + * Metadata about the phishing data set + */ +export type PhishingDataMeta = { + /** The last known checksum of the phishing data set */ checksum: string; - + /** The last time the data set was updated */ + timestamp: number; /** * We store the application version to refetch the entire dataset on a new client release. * This counteracts daily appends updates not removing inactive or false positive web addresses. @@ -32,30 +34,42 @@ export type PhishingData = { applicationVersion: string; }; -export const PHISHING_DOMAINS_KEY = new KeyDefinition( +/** + * The phishing data blob is a string representation of the phishing web addresses + */ +export type PhishingDataBlob = string; +export type PhishingData = { meta: PhishingDataMeta; blob: PhishingDataBlob }; + +export const PHISHING_DOMAINS_META_KEY = new KeyDefinition( PHISHING_DETECTION_DISK, - "phishingDomains", + "phishingDomainsMeta", { - deserializer: (value: PhishingData) => - value ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }, + deserializer: (value: PhishingDataMeta) => { + return { + checksum: value?.checksum ?? "", + timestamp: value?.timestamp ?? 0, + applicationVersion: value?.applicationVersion ?? "", + }; + }, + }, +); + +export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( + PHISHING_DETECTION_DISK, + "phishingDomainsBlob", + { + deserializer: (value: string) => value ?? "", }, ); /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { - private _testWebAddresses = this.getTestWebAddresses(); - private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); - private _webAddresses$ = this._cachedState.state$.pipe( - map( - (state) => - new Set( - (state?.webAddresses?.filter((line) => line.trim().length > 0) ?? []).concat( - this._testWebAddresses, - "phishing.testcategory.com", // Included for QA to test in prod - ), - ), - ), - ); + private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod + private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); + private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY); + + // In-memory set loaded from blob for fast lookups without reading large storage repeatedly + private _webAddressesSet: Set | null = null; // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours @@ -64,10 +78,11 @@ export class PhishingDataService { update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once switchMap(() => - this._cachedState.state$.pipe( + this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below - tap((cachedState) => { - void this._backgroundUpdate(cachedState); + tap((metaState) => { + // Perform any updates in the background if needed + void this._backgroundUpdate(metaState); }), catchError((err: unknown) => { this.logService.error("[PhishingDataService] Background update failed to start.", err); @@ -86,6 +101,7 @@ export class PhishingDataService { private platformUtilsService: PlatformUtilsService, private resourceType: PhishingResourceType = PhishingResourceType.Links, ) { + this.logService.debug("[PhishingDataService] Initializing service..."); this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { this._triggerUpdate$.next(); }); @@ -93,6 +109,7 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); + void this._loadBlobToMemory(); } /** @@ -102,12 +119,17 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - // Use domain (hostname) matching for domain resources, and link matching for links resources - const entries = await firstValueFrom(this._webAddresses$); + if (!this._webAddressesSet) { + this.logService.debug("[PhishingDataService] Set not loaded; skipping check"); + return false; + } + const set = this._webAddressesSet!; const resource = getPhishingResources(this.resourceType); - if (resource && resource.match) { - for (const entry of entries) { + + // Custom matcher per resource + if (resource && resource?.match) { + for (const entry of set) { if (resource.match(url, entry)) { return true; } @@ -115,54 +137,59 @@ export class PhishingDataService { return false; } - // Default/domain behavior: exact hostname match as a fallback - return entries.has(url.hostname); + // Default set-based lookup + return set.has(url.hostname); } - async getNextWebAddresses(prev: PhishingData | null): Promise { - prev = prev ?? { webAddresses: [], timestamp: 0, checksum: "", applicationVersion: "" }; - const timestamp = Date.now(); - const prevAge = timestamp - prev.timestamp; - this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`); + async getNextWebAddresses( + previous: PhishingDataMeta | null, + ): Promise | null> { + const prevMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "" }; + const now = Date.now(); + // Updates to check const applicationVersion = await this.platformUtilsService.getApplicationVersion(); - - // If checksum matches, return existing data with new timestamp & version const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - if (remoteChecksum && prev.checksum === remoteChecksum) { - this.logService.info( - `[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`, - ); - return { ...prev, timestamp, applicationVersion }; - } - // Checksum is different, data needs to be updated. - // Approach 1: Fetch only new web addresses and append - const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; - if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { - const webAddressesTodayUrl = getPhishingResources(this.resourceType)!.todayUrl; - const dailyWebAddresses: string[] = - await this.fetchPhishingWebAddresses(webAddressesTodayUrl); - this.logService.info( - `[PhishingDataService] ${dailyWebAddresses.length} new phishing web addresses added`, - ); + // 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 { - webAddresses: prev.webAddresses.concat(dailyWebAddresses), - checksum: remoteChecksum, - timestamp, - applicationVersion, + blob, + meta: { checksum: remoteChecksum, timestamp: now, applicationVersion }, }; } - // Approach 2: Fetch all web addresses - const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; - const remoteWebAddresses = await this.fetchPhishingWebAddresses(remoteUrl); - return { - webAddresses: remoteWebAddresses, - timestamp, - checksum: remoteChecksum, - 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; } private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) { @@ -173,8 +200,24 @@ export class PhishingDataService { } return response.text(); } + private async fetchAndCompress(url: string): Promise { + const response = await this.apiService.nativeFetch(new Request(url)); + if (!response.ok) { + throw new Error("Fetch failed"); + } - private async fetchPhishingWebAddresses(url: string) { + 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); + + // Return as Base64 for storage + return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes); + } + + private async fetchText(url: string) { const response = await this.apiService.nativeFetch(new Request(url)); if (!response.ok) { @@ -202,10 +245,9 @@ export class PhishingDataService { } // Runs the update flow in the background and retries up to 3 times on failure - private async _backgroundUpdate(prev: PhishingData | null): Promise { - this.logService.info(`[PhishingDataService] Update triggered...`); - const phishingData = prev ?? { - webAddresses: [], + private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise { + this.logService.info(`[PhishingDataService] Update web addresses triggered...`); + const phishingMeta: PhishingDataMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "", @@ -217,15 +259,22 @@ export class PhishingDataService { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - const next = await this.getNextWebAddresses(phishingData); - if (next) { - await this._cachedState.update(() => next); - - // Performance logging - const elapsed = Date.now() - startTime; - this.logService.info(`[PhishingDataService] cache updated in ${elapsed}ms`); + const next = await this.getNextWebAddresses(phishingMeta); + if (!next) { + return; // No update needed } - return; + + if (next.meta) { + await this._phishingMetaState.update(() => next!.meta!); + } + if (next.blob) { + await this._phishingBlobState.update(() => next!.blob!); + await this._loadBlobToMemory(); + } + + // 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}.`, @@ -243,4 +292,87 @@ export class PhishingDataService { } } } + + // [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 { + 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 { + 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)); + } + } + + // Try to load compressed newline blob into an in-memory Set for fast lookups + private async _loadBlobToMemory(): Promise { + this.logService.debug("[PhishingDataService] Loading data blob into memory..."); + try { + const blobBase64 = await firstValueFrom(this._phishingBlobState.state$); + if (!blobBase64) { + return; + } + + const text = await this._decompressString(blobBase64); + // Split and filter + const lines = text.split(/\r?\n/); + const newWebAddressesSet = new Set(lines); + + // Add test addresses + this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); + this._webAddressesSet = new Set(newWebAddressesSet); + this.logService.info( + `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, + ); + } catch (err) { + this.logService.error("[PhishingDataService] Failed to load blob into memory", err); + } + } + 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)); + } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462e798a42..06a021085ea 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -537,6 +537,7 @@ const safeProviders: SafeProvider[] = [ AccountService, BillingAccountProfileStateService, ConfigService, + LogService, OrganizationService, PlatformUtilsService, StateProvider, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index 6728249b788..b999d8db35a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -405,4 +405,42 @@ describe("ItemMoreOptionsComponent", () => { }); }); }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(false)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + jest.spyOn(component["organizationService"], "hasOrganizations").mockReturnValue(of(true)); + jest + .spyOn(component["collectionService"], "decryptedCollections$") + .mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); }); diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index 3273ca612fe..16afab4384b 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.html +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -63,6 +63,15 @@ + @if (canAssignCollections$ | async) { + + } diff --git a/apps/browser/src/vault/popup/settings/archive.component.spec.ts b/apps/browser/src/vault/popup/settings/archive.component.spec.ts new file mode 100644 index 00000000000..6ad5c2c2907 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/archive.component.spec.ts @@ -0,0 +1,135 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { ArchiveComponent } from "./archive.component"; + +describe("ArchiveComponent", () => { + let component: ArchiveComponent; + + let hasOrganizations: jest.Mock; + let decryptedCollections$: jest.Mock; + let navigate: jest.Mock; + let showPasswordPrompt: jest.Mock; + + beforeAll(async () => { + navigate = jest.fn(); + showPasswordPrompt = jest.fn().mockResolvedValue(true); + hasOrganizations = jest.fn(); + decryptedCollections$ = jest.fn(); + + await TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: { navigate } }, + { + provide: AccountService, + useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) }, + }, + { provide: PasswordRepromptService, useValue: { showPasswordPrompt } }, + { provide: OrganizationService, useValue: { hasOrganizations } }, + { provide: CollectionService, useValue: { decryptedCollections$ } }, + { provide: DialogService, useValue: mock() }, + { provide: CipherService, useValue: mock() }, + { provide: CipherArchiveService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: PopupRouterCacheService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(ArchiveComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("canAssignCollections$", () => { + it("emits true when user has organizations and editable collections", (done) => { + hasOrganizations.mockReturnValue(of(true)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("emits false when user has no organizations", (done) => { + hasOrganizations.mockReturnValue(of(false)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: false }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("emits false when all collections are read-only", (done) => { + hasOrganizations.mockReturnValue(of(true)); + decryptedCollections$.mockReturnValue(of([{ id: "col-1", readOnly: true }] as any)); + + component["canAssignCollections$"].subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); + + describe("conditionallyNavigateToAssignCollections", () => { + const mockCipher = { + id: "cipher-1", + reprompt: 0, + } as CipherViewLike; + + it("navigates to assign-collections when reprompt is not required", async () => { + await component.conditionallyNavigateToAssignCollections(mockCipher); + + expect(navigate).toHaveBeenCalledWith(["/assign-collections"], { + queryParams: { cipherId: "cipher-1" }, + }); + }); + + it("prompts for password when reprompt is required", async () => { + const cipherWithReprompt = { ...mockCipher, reprompt: 1 }; + + await component.conditionallyNavigateToAssignCollections( + cipherWithReprompt as CipherViewLike, + ); + + expect(showPasswordPrompt).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(["/assign-collections"], { + queryParams: { cipherId: "cipher-1" }, + }); + }); + + it("does not navigate when password prompt is cancelled", async () => { + const cipherWithReprompt = { ...mockCipher, reprompt: 1 }; + showPasswordPrompt.mockResolvedValueOnce(false); + + await component.conditionallyNavigateToAssignCollections( + cipherWithReprompt as CipherViewLike, + ); + + expect(showPasswordPrompt).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts index 2b151116e20..2a46ac0c46e 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.ts +++ b/apps/browser/src/vault/popup/settings/archive.component.ts @@ -1,9 +1,11 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable, startWith, switchMap } from "rxjs"; +import { CollectionService } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -71,6 +73,9 @@ export class ArchiveComponent { private i18nService = inject(I18nService); private cipherArchiveService = inject(CipherArchiveService); private passwordRepromptService = inject(PasswordRepromptService); + private organizationService = inject(OrganizationService); + private collectionService = inject(CollectionService); + private userId$: Observable = this.accountService.activeAccount$.pipe(getUserId); protected archivedCiphers$ = this.userId$.pipe( @@ -87,6 +92,20 @@ export class ArchiveComponent { startWith(true), ); + protected canAssignCollections$ = this.userId$.pipe( + switchMap((userId) => { + return combineLatest([ + this.organizationService.hasOrganizations(userId), + this.collectionService.decryptedCollections$(userId), + ]).pipe( + map(([hasOrgs, collections]) => { + const canEditCollections = collections.some((c) => !c.readOnly); + return hasOrgs && canEditCollections; + }), + ); + }), + ); + protected showSubscriptionEndedMessaging$ = this.userId$.pipe( switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)), ); @@ -187,6 +206,17 @@ export class ArchiveComponent { }); } + /** Prompts for password when necessary then navigates to the assign collections route */ + async conditionallyNavigateToAssignCollections(cipher: CipherViewLike) { + if (cipher.reprompt && !(await this.passwordRepromptService.showPasswordPrompt())) { + return; + } + + await this.router.navigate(["/assign-collections"], { + queryParams: { cipherId: cipher.id }, + }); + } + /** * Check if the user is able to interact with the cipher * (password re-prompt / decryption failure checks). diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 8a6f720bb45..a40cb3d4330 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -19,7 +19,7 @@ @if (SendUIRefresh$ | async) { -
+
{ expect(overlayContent).not.toContain('appcopyfield="password"'); }); }); + + describe("showAssignToCollections", () => { + let archivedCipher: CipherView; + + beforeEach(() => { + archivedCipher = new CipherView(); + archivedCipher.id = "cipher-1"; + archivedCipher.name = "Test Cipher"; + archivedCipher.type = CipherType.Login; + archivedCipher.organizationId = "org-1"; + archivedCipher.deletedDate = null; + archivedCipher.archivedDate = new Date(); + + component.cipher = archivedCipher; + component.organizations = [{ id: "org-1" } as any]; + component.canAssignCollections = true; + component.disabled = false; + }); + + it("returns true when cipher is archived and conditions are met", () => { + expect(component["showAssignToCollections"]).toBe(true); + }); + + it("returns false when cipher is deleted", () => { + archivedCipher.deletedDate = new Date(); + + expect(component["showAssignToCollections"]).toBe(false); + }); + + it("returns false when user cannot assign collections", () => { + component.canAssignCollections = false; + + expect(component["showAssignToCollections"]).toBe(false); + }); + + it("returns false when there are no organizations", () => { + component.organizations = []; + + expect(component["showAssignToCollections"]).toBeFalsy(); + }); + }); }); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index df1e70723ca..ec0fe42f927 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -217,11 +217,7 @@ export class VaultCipherRowComponent implements OnInit return CipherViewLikeUtils.decryptionFailure(this.cipher); } - // Do Not show Assign to Collections option if item is archived protected get showAssignToCollections() { - if (CipherViewLikeUtils.isArchived(this.cipher)) { - return false; - } return ( this.organizations?.length && this.canAssignCollections && diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 98d574c83cc..de77354efb6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12647,5 +12647,8 @@ }, "youHavePremium": { "message": "You have Premium" + }, + "emailProtected": { + "message": "Email protected" } } diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts index e6363b490cb..077d28f5954 100644 --- a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.spec.ts @@ -8,6 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { ProductTierType } from "@bitwarden/common/billing/enums"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LogService } from "@bitwarden/logging"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; import { UserId } from "../../../types/guid"; @@ -54,6 +55,8 @@ describe("PhishingDetectionSettingsService", () => { usePhishingBlocker: true, }); + const mockLogService = mock(); + const mockUserId = "mock-user-id" as UserId; const account = mock({ id: mockUserId }); const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); @@ -85,6 +88,7 @@ describe("PhishingDetectionSettingsService", () => { mockAccountService, mockBillingService, mockConfigService, + mockLogService, mockOrganizationService, mockPlatformService, stateProvider, diff --git a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts index e30592b2f68..91ae7c6227e 100644 --- a/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts +++ b/libs/common/src/dirt/services/phishing-detection/phishing-detection-settings.service.ts @@ -1,5 +1,5 @@ import { combineLatest, Observable, of, switchMap } from "rxjs"; -import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators"; +import { catchError, distinctUntilChanged, map, shareReplay, tap } from "rxjs/operators"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -9,6 +9,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { LogService } from "@bitwarden/logging"; import { UserId } from "@bitwarden/user-core"; import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state"; @@ -32,27 +33,47 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin private accountService: AccountService, private billingService: BillingAccountProfileStateService, private configService: ConfigService, + private logService: LogService, private organizationService: OrganizationService, private platformService: PlatformUtilsService, private stateProvider: StateProvider, ) { + this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`); this.available$ = this.buildAvailablePipeline$().pipe( distinctUntilChanged(), + tap((available) => + this.logService.debug( + `[PhishingDetectionSettingsService] Phishing detection available: ${available}`, + ), + ), shareReplay({ bufferSize: 1, refCount: true }), ); this.enabled$ = this.buildEnabledPipeline$().pipe( distinctUntilChanged(), + tap((enabled) => + this.logService.debug( + `[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`, + ), + ), shareReplay({ bufferSize: 1, refCount: true }), ); this.on$ = combineLatest([this.available$, this.enabled$]).pipe( map(([available, enabled]) => available && enabled), distinctUntilChanged(), - shareReplay({ bufferSize: 1, refCount: true }), + tap((on) => + this.logService.debug( + `[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`, + ), + ), + shareReplay({ bufferSize: 1, refCount: false }), ); } async setEnabled(userId: UserId, enabled: boolean): Promise { + this.logService.debug( + `[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`, + ); await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled); } @@ -64,6 +85,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin private buildAvailablePipeline$(): Observable { // Phishing detection is unavailable on Safari due to platform limitations. if (this.platformService.isSafari()) { + this.logService.warning( + `[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`, + ); return of(false); } @@ -97,6 +121,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin if (!account) { return of(false); } + this.logService.debug( + `[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`, + ); return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id); }), map((enabled) => enabled ?? true), diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index bfa72b04087..7eeb15f3ebe 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendResponse } from "../response/send.response"; @@ -10,6 +11,7 @@ export class SendData { id: string; accessId: string; type: SendType; + authType: AuthType; name: string; notes: string; file: SendFileData; @@ -33,6 +35,7 @@ export class SendData { this.id = response.id; this.accessId = response.accessId; this.type = response.type; + this.authType = response.authType; this.name = response.name; this.notes = response.notes; this.key = response.key; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index b0cfd200483..cd51390908e 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -11,6 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../../../platform/services/container.service"; import { UserKey } from "../../../../types/key"; +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; @@ -25,6 +26,7 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, + authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -55,6 +57,7 @@ describe("Send", () => { id: null, accessId: null, type: undefined, + authType: undefined, name: null, notes: null, text: undefined, @@ -78,6 +81,7 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, + authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -107,6 +111,7 @@ describe("Send", () => { send.id = "id"; send.accessId = "accessId"; send.type = SendType.Text; + send.authType = AuthType.None; send.name = mockEnc("name"); send.notes = mockEnc("notes"); send.text = text; @@ -145,6 +150,7 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, + authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index b85509183b0..82c37a17528 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -8,6 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; @@ -19,6 +20,7 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; + authType: AuthType; name: EncString; notes: EncString; file: SendFile; @@ -54,6 +56,7 @@ export class Send extends Domain { ); this.type = obj.type; + this.authType = obj.authType; this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 6bbaf91ebe8..7a7885d5ae1 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { BaseResponse } from "../../../../models/response/base.response"; +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -9,6 +10,7 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; + authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -29,6 +31,7 @@ export class SendResponse extends BaseResponse { this.id = this.getResponseProperty("Id"); this.accessId = this.getResponseProperty("AccessId"); this.type = this.getResponseProperty("Type"); + this.authType = this.getResponseProperty("AuthType"); this.name = this.getResponseProperty("Name"); this.notes = this.getResponseProperty("Notes"); this.key = this.getResponseProperty("Key"); diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index 1bb3b527a73..d07de6d8293 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -4,6 +4,7 @@ import { View } from "../../../../models/view/view"; import { Utils } from "../../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; +import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { Send } from "../domain/send"; @@ -18,6 +19,7 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; + authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; @@ -38,6 +40,7 @@ export class SendView implements View { this.id = s.id; this.accessId = s.accessId; this.type = s.type; + this.authType = s.authType; this.maxAccessCount = s.maxAccessCount; this.accessCount = s.accessCount; this.revisionDate = s.revisionDate; diff --git a/libs/common/src/tools/send/types/auth-type.ts b/libs/common/src/tools/send/types/auth-type.ts new file mode 100644 index 00000000000..5d0243249fd --- /dev/null +++ b/libs/common/src/tools/send/types/auth-type.ts @@ -0,0 +1,12 @@ +/** An type of auth necessary to access a Send */ +export const AuthType = Object.freeze({ + /** Send requires email OTP verification */ + Email: 0, + /** Send requires a password */ + Password: 1, + /** Send requires no auth */ + None: 2, +} as const); + +/** An type of auth necessary to access a Send */ +export type AuthType = (typeof AuthType)[keyof typeof AuthType]; diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.html b/libs/tools/send/send-ui/src/send-table/send-table.component.html index 96b9519019e..cc2fca2c41c 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.html +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.html @@ -33,14 +33,16 @@ > {{ "disabled" | i18n }} } - @if (s.password) { + @if (s.authType !== authType.None) { + @let titleKey = + s.authType === authType.Email ? "emailProtected" : "passwordProtected"; - {{ "password" | i18n }} + {{ titleKey | i18n }} } @if (s.maxAccessCountReached) { = {}): SendView send.id = `send-${id}`; send.name = "My Send"; send.type = SendType.Text; + send.authType = AuthType.None; send.deletionDate = new Date("2030-01-01T12:00:00Z"); send.password = null as any; @@ -34,21 +36,29 @@ dataSource.data = [ createMockSend(2, { name: "Password Protected Send", type: SendType.Text, + authType: AuthType.Password, password: "123", }), createMockSend(3, { + name: "Email Protected Send", + type: SendType.Text, + authType: AuthType.Email, + emails: ["ckent@dailyplanet.com"], + }), + createMockSend(4, { name: "Disabled Send", type: SendType.Text, disabled: true, }), - createMockSend(4, { + createMockSend(5, { name: "Expired Send", type: SendType.File, expirationDate: new Date("2025-12-01T00:00:00Z"), }), - createMockSend(5, { + createMockSend(6, { name: "Max Access Reached", type: SendType.Text, + authType: AuthType.Password, maxAccessCount: 5, accessCount: 5, password: "123", @@ -69,7 +79,8 @@ export default { deletionDate: "Deletion Date", options: "Options", disabled: "Disabled", - password: "Password", + passwordProtected: "Password protected", + emailProtected: "Email protected", maxAccessCountReached: "Max access count reached", expired: "Expired", pendingDeletion: "Pending deletion", diff --git a/libs/tools/send/send-ui/src/send-table/send-table.component.ts b/libs/tools/send/send-ui/src/send-table/send-table.component.ts index e46f59bab17..1475d9c65d1 100644 --- a/libs/tools/send/send-ui/src/send-table/send-table.component.ts +++ b/libs/tools/send/send-ui/src/send-table/send-table.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BadgeModule, @@ -37,6 +38,7 @@ import { }) export class SendTableComponent { protected readonly sendType = SendType; + protected readonly authType = AuthType; /** * The data source containing the Send items to display in the table.