From 9a22907e276b0452aa81f0d88dd1e87757f576d6 Mon Sep 17 00:00:00 2001
From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com>
Date: Thu, 15 Jan 2026 11:08:18 -0600
Subject: [PATCH 1/8] [PM-30296] Assign to Collections for Archived Ciphers
(#18223)
* allow for archived ciphers to be assigned to a collection via the more options menu
* reference `userId$` directly
---
.../item-more-options.component.spec.ts | 38 +++++
.../popup/settings/archive.component.html | 9 ++
.../popup/settings/archive.component.spec.ts | 135 ++++++++++++++++++
.../vault/popup/settings/archive.component.ts | 32 ++++-
.../vault-cipher-row.component.spec.ts | 41 ++++++
.../vault-items/vault-cipher-row.component.ts | 4 -
6 files changed, 254 insertions(+), 5 deletions(-)
create mode 100644 apps/browser/src/vault/popup/settings/archive.component.spec.ts
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/vault/components/vault-items/vault-cipher-row.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts
index 9378ee54e51..49c9df8d582 100644
--- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts
+++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.spec.ts
@@ -142,4 +142,45 @@ describe("VaultCipherRowComponent", () => {
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 &&
From 535b958f9e4edf5f0132b4859801bba6347f1dc1 Mon Sep 17 00:00:00 2001
From: Daniel Riera
Date: Thu, 15 Jan 2026 12:09:55 -0500
Subject: [PATCH 2/8] [PM-29523] Remove ts strict ignore in browser context
menu clicked handler (#18264)
* early return if no cipher before switch case
* explicit null checks within switch cases for early returns
* lower cipher check and add to explicit checks
* add test cases for null values
* format spec file
---
.../context-menu-clicked-handler.spec.ts | 90 +++++++++++++++++++
.../browser/context-menu-clicked-handler.ts | 29 +++++-
2 files changed, 116 insertions(+), 3 deletions(-)
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) => {
From 8cfd83e4268836bf3d5df41b1bcd262bd4614a95 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 15 Jan 2026 11:25:32 -0600
Subject: [PATCH 3/8] [deps]: Update actions/checkout action to v6 (#18384)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/build-desktop.yml | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
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 }}
From 21ea969daaeb8a6750c3c4d968f58afcef031bcb Mon Sep 17 00:00:00 2001
From: Daniel Riera
Date: Thu, 15 Jan 2026 12:44:23 -0500
Subject: [PATCH 4/8] [PM-29520] Remove ts strict ignore in background overlay
notifications background (#18243)
* initialize timer to null
* default undefined length to 0 using nullish operator
* optional chaining and explicit null check on tab presence
* add optional chainning where relevant for sender tab id
* explicit null checks and data guards set for sender and modifyLoginData
* address feedback and make explicit undefined checks to avoid possible valid tabid rejection
* explicit tab check on setupNotificationInitTrigger
---
.../overlay-notifications.background.ts | 70 ++++++++++++-------
1 file changed, 44 insertions(+), 26 deletions(-)
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);
};
From 417dfdd305d3099bfdc23d5ba172d98ce789e95a Mon Sep 17 00:00:00 2001
From: Leslie Tilton <23057410+Banrion@users.noreply.github.com>
Date: Thu, 15 Jan 2026 12:36:03 -0600
Subject: [PATCH 5/8] [PM-30319][PM-30685] Separate and compress phishing data
storage (#18337)
* Add logs for debugging in phishing-detection-settings.service
* Update phishing data service to separate web addresses from meta data for performant writes. Store compressed string instead of array
* Cleanup
* Updated test cases
* Cleanup comments
* Fix fallback encoding/decoding mismatch
* Fix type checking
---
.../browser/src/background/main.background.ts | 1 +
.../services/phishing-data.service.spec.ts | 291 +++++++++++++----
.../services/phishing-data.service.ts | 292 +++++++++++++-----
.../src/popup/services/services.module.ts | 1 +
...hishing-detection-settings.service.spec.ts | 4 +
.../phishing-detection-settings.service.ts | 31 +-
6 files changed, 478 insertions(+), 142 deletions(-)
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/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),
From 9f74178928f1e844afdebd9b1617d7ecf42a8d61 Mon Sep 17 00:00:00 2001
From: Mike Amirault
Date: Thu, 15 Jan 2026 14:19:43 -0500
Subject: [PATCH 6/8] =?UTF-8?q?[PM-21774]=20Adjust=20icon=20and=20tooltip?=
=?UTF-8?q?=20for=20protected=20Sends=20on=20the=20Sends=20l=E2=80=A6=20(#?=
=?UTF-8?q?18293)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [PM-21774] Adjust icon and tooltip for protected Sends on the Sends list page
* [PM-21774] Update Sent table UI stories
* [PM-21774] Fix Send table UI story
---
apps/web/src/locales/en/messages.json | 3 +++
.../src/tools/send/models/data/send.data.ts | 3 +++
.../src/tools/send/models/domain/send.spec.ts | 6 ++++++
.../common/src/tools/send/models/domain/send.ts | 3 +++
.../tools/send/models/response/send.response.ts | 3 +++
.../src/tools/send/models/view/send.view.ts | 3 +++
libs/common/src/tools/send/types/auth-type.ts | 12 ++++++++++++
.../src/send-table/send-table.component.html | 10 ++++++----
.../send-table/send-table.component.stories.ts | 17 ++++++++++++++---
.../src/send-table/send-table.component.ts | 2 ++
10 files changed, 55 insertions(+), 7 deletions(-)
create mode 100644 libs/common/src/tools/send/types/auth-type.ts
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 8adfaac88f2..ecad9f8a624 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -12626,5 +12626,8 @@
},
"youHavePremium": {
"message": "You have Premium"
+ },
+ "emailProtected": {
+ "message": "Email protected"
}
}
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.
From 320fe1f1c94818b43798ec72a7975d40b4489d64 Mon Sep 17 00:00:00 2001
From: Mike Amirault
Date: Thu, 15 Jan 2026 15:12:11 -0500
Subject: [PATCH 7/8] [PM-30382] Make Send search bar full page width (#18386)
---
apps/web/src/app/tools/send/send.component.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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) {
-
+
Date: Thu, 15 Jan 2026 15:24:32 -0600
Subject: [PATCH 8/8] [PM-30287] Archive deletion navigation (#18213)
* add `routeAfterDeletion` for edit screen to redirect the user to the correct location after deleting an archived cipher
* use `historyGo` to preserve the back invocations
* fix duplicate import
---
.../add-edit/add-edit-v2.component.spec.ts | 104 +++++++++++++++++-
.../add-edit/add-edit-v2.component.ts | 46 +++++++-
.../vault-v2/view-v2/view-v2.component.ts | 12 +-
.../popup/settings/archive.component.spec.ts | 5 +
.../vault/popup/settings/archive.component.ts | 13 ++-
5 files changed, 173 insertions(+), 7 deletions(-)
diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts
index fb58f1e2240..8ea23e7e2b9 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.spec.ts
@@ -1,5 +1,7 @@
+import { Location } from "@angular/common";
import { ComponentFixture, fakeAsync, TestBed, 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 { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
@@ -59,6 +61,8 @@ describe("AddEditV2Component", () => {
const back = jest.fn().mockResolvedValue(null);
const setHistory = jest.fn();
const collect = jest.fn().mockResolvedValue(null);
+ const history$ = jest.fn();
+ const historyGo = jest.fn().mockResolvedValue(null);
const openSimpleDialog = jest.fn().mockResolvedValue(true);
const cipherArchiveService = mock();
@@ -68,6 +72,8 @@ describe("AddEditV2Component", () => {
navigate.mockClear();
back.mockClear();
collect.mockClear();
+ history$.mockClear();
+ historyGo.mockClear();
openSimpleDialog.mockClear();
cipherArchiveService.hasArchiveFlagEnabled$ = of(true);
@@ -81,11 +87,13 @@ describe("AddEditV2Component", () => {
await TestBed.configureTestingModule({
imports: [AddEditV2Component],
providers: [
+ provideNoopAnimations(),
{ provide: PlatformUtilsService, useValue: mock() },
{ provide: ConfigService, useValue: mock() },
- { provide: PopupRouterCacheService, useValue: { back, setHistory } },
+ { provide: PopupRouterCacheService, useValue: { back, setHistory, history$ } },
{ provide: PopupCloseWarningService, useValue: { disable } },
{ provide: Router, useValue: { navigate } },
+ { provide: Location, useValue: { historyGo } },
{ provide: ActivatedRoute, useValue: { queryParams: queryParams$ } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: CipherService, useValue: cipherServiceMock },
@@ -558,12 +566,104 @@ describe("AddEditV2Component", () => {
expect(deleteCipherSpy).toHaveBeenCalled();
});
- it("navigates to vault tab after deletion", async () => {
+ it("navigates to vault tab after deletion by default", async () => {
jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
await component.delete();
expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
});
+
+ it("navigates to custom route when not in history", fakeAsync(() => {
+ buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher;
+ queryParams$.next({
+ cipherId: "123",
+ routeAfterDeletion: "/archive",
+ });
+
+ tick();
+
+ // Mock history without the target route
+ history$.mockReturnValue(
+ of([
+ { url: "/tabs/vault" },
+ { url: "/view-cipher?cipherId=123" },
+ { url: "/add-edit?cipherId=123" },
+ ]),
+ );
+
+ jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
+
+ void component.delete();
+ tick();
+
+ expect(history$).toHaveBeenCalled();
+ expect(historyGo).not.toHaveBeenCalled();
+ expect(navigate).toHaveBeenCalledWith(["/archive"]);
+ }));
+
+ it("uses historyGo when custom route exists in history", fakeAsync(() => {
+ buildConfigResponse.originalCipher = { edit: true, id: "123" } as Cipher;
+ queryParams$.next({
+ cipherId: "123",
+ routeAfterDeletion: "/archive",
+ });
+
+ tick();
+
+ history$.mockReturnValue(
+ of([
+ { url: "/tabs/vault" },
+ { url: "/archive" },
+ { url: "/view-cipher?cipherId=123" },
+ { url: "/add-edit?cipherId=123" },
+ ]),
+ );
+
+ jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
+
+ void component.delete();
+ tick();
+
+ expect(history$).toHaveBeenCalled();
+ expect(historyGo).toHaveBeenCalledWith(-2);
+ expect(navigate).not.toHaveBeenCalled();
+ }));
+
+ it("uses router.navigate for default /tabs/vault route", fakeAsync(() => {
+ buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher;
+ component.routeAfterDeletion = "/tabs/vault";
+
+ queryParams$.next({
+ cipherId: "456",
+ });
+
+ tick();
+
+ jest.spyOn(component["dialogService"], "openSimpleDialog").mockResolvedValue(true);
+
+ void component.delete();
+ tick();
+
+ expect(history$).not.toHaveBeenCalled();
+ expect(historyGo).not.toHaveBeenCalled();
+ expect(navigate).toHaveBeenCalledWith(["/tabs/vault"]);
+ }));
+
+ it("ignores invalid routeAfterDeletion query param and uses default route", fakeAsync(() => {
+ // Reset the component's routeAfterDeletion to default before this test
+ component.routeAfterDeletion = "/tabs/vault";
+
+ buildConfigResponse.originalCipher = { edit: true, id: "456" } as Cipher;
+ queryParams$.next({
+ cipherId: "456",
+ routeAfterDeletion: "/invalid/route",
+ });
+
+ tick();
+
+ // The invalid route should be ignored, routeAfterDeletion should remain default
+ expect(component.routeAfterDeletion).toBe("/tabs/vault");
+ }));
});
describe("reloadAddEditCipherData", () => {
diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts
index 8fa17502d42..895a5fe0cce 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts
@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
-import { CommonModule } from "@angular/common";
+import { CommonModule, Location } from "@angular/common";
import { Component, OnInit, OnDestroy, viewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
@@ -64,6 +64,18 @@ import {
import { VaultPopoutType } from "../../../utils/vault-popout-window";
import { OpenAttachmentsComponent } from "../attachments/open-attachments/open-attachments.component";
+/**
+ * Available routes to navigate to after editing a cipher.
+ * Useful when the user could be coming from a different view other than the main vault (e.g., archive).
+ */
+export const ROUTES_AFTER_EDIT_DELETION = Object.freeze({
+ tabsVault: "/tabs/vault",
+ archive: "/archive",
+} as const);
+
+export type ROUTES_AFTER_EDIT_DELETION =
+ (typeof ROUTES_AFTER_EDIT_DELETION)[keyof typeof ROUTES_AFTER_EDIT_DELETION];
+
/**
* Helper class to parse query parameters for the AddEdit route.
*/
@@ -79,6 +91,7 @@ class QueryParams {
this.username = params.username;
this.name = params.name;
this.prefillNameAndURIFromTab = params.prefillNameAndURIFromTab;
+ this.routeAfterDeletion = params.routeAfterDeletion ?? ROUTES_AFTER_EDIT_DELETION.tabsVault;
}
/**
@@ -131,6 +144,12 @@ class QueryParams {
* NOTE: This will override the `uri` and `name` query parameters if set to true.
*/
prefillNameAndURIFromTab?: true;
+
+ /**
+ * The view that will be navigated to after deleting the cipher.
+ * @default "/tabs/vault"
+ */
+ routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION;
}
export type AddEditQueryParams = Partial>;
@@ -168,6 +187,7 @@ export class AddEditV2Component implements OnInit, OnDestroy {
headerText: string;
config: CipherFormConfig;
canDeleteCipher$: Observable;
+ routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION = "/tabs/vault";
get loading() {
return this.config == null;
@@ -221,6 +241,7 @@ export class AddEditV2Component implements OnInit, OnDestroy {
private dialogService: DialogService,
protected cipherAuthorizationService: CipherAuthorizationService,
private accountService: AccountService,
+ private location: Location,
private archiveService: CipherArchiveService,
private archiveCipherUtilsService: ArchiveCipherUtilitiesService,
) {
@@ -407,6 +428,13 @@ export class AddEditV2Component implements OnInit, OnDestroy {
);
}
+ if (
+ params.routeAfterDeletion &&
+ Object.values(ROUTES_AFTER_EDIT_DELETION).includes(params.routeAfterDeletion)
+ ) {
+ this.routeAfterDeletion = params.routeAfterDeletion;
+ }
+
return config;
}),
)
@@ -514,7 +542,21 @@ export class AddEditV2Component implements OnInit, OnDestroy {
return false;
}
- await this.router.navigate(["/tabs/vault"]);
+ if (this.routeAfterDeletion !== ROUTES_AFTER_EDIT_DELETION.tabsVault) {
+ const history = await firstValueFrom(this.popupRouterCacheService.history$());
+ const targetIndex = history.map((h) => h.url).lastIndexOf(this.routeAfterDeletion);
+
+ if (targetIndex !== -1) {
+ const stepsBack = targetIndex - (history.length - 1);
+ // Use historyGo to navigate back to the target route in history
+ // This allows downstream calls to `back()` to continue working as expected
+ await this.location.historyGo(stepsBack);
+ } else {
+ await this.router.navigate([this.routeAfterDeletion]);
+ }
+ } else {
+ await this.router.navigate([this.routeAfterDeletion]);
+ }
this.toastService.showToast({
variant: "success",
diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts
index b5c5de032d6..f57b3e2d7f1 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts
@@ -61,6 +61,7 @@ import { BrowserPremiumUpgradePromptService } from "../../../services/browser-pr
import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service";
import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service";
import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window";
+import { ROUTES_AFTER_EDIT_DELETION } from "../add-edit/add-edit-v2.component";
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component";
@@ -116,6 +117,7 @@ export class ViewV2Component {
collections$: Observable;
loadAction: LoadAction;
senderTabId?: number;
+ routeAfterDeletion?: ROUTES_AFTER_EDIT_DELETION;
protected showFooter$: Observable;
protected userCanArchive$ = this.accountService.activeAccount$
@@ -151,6 +153,9 @@ export class ViewV2Component {
switchMap(async (params) => {
this.loadAction = params.action;
this.senderTabId = params.senderTabId ? parseInt(params.senderTabId, 10) : undefined;
+ this.routeAfterDeletion = params.routeAfterDeletion
+ ? params.routeAfterDeletion
+ : undefined;
this.activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
@@ -230,7 +235,12 @@ export class ViewV2Component {
return false;
}
void this.router.navigate(["/edit-cipher"], {
- queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false },
+ queryParams: {
+ cipherId: this.cipher.id,
+ type: this.cipher.type,
+ isNew: false,
+ routeAfterDeletion: this.routeAfterDeletion,
+ },
});
return true;
}
diff --git a/apps/browser/src/vault/popup/settings/archive.component.spec.ts b/apps/browser/src/vault/popup/settings/archive.component.spec.ts
index 6ad5c2c2907..2f5cfb8d824 100644
--- a/apps/browser/src/vault/popup/settings/archive.component.spec.ts
+++ b/apps/browser/src/vault/popup/settings/archive.component.spec.ts
@@ -18,6 +18,11 @@ import { PasswordRepromptService } from "@bitwarden/vault";
import { ArchiveComponent } from "./archive.component";
+// 'qrcode-parser' is used by `BrowserTotpCaptureService` but is an es6 module that jest can't compile.
+// Mock the entire module here to prevent jest from throwing an error. I wasn't able to find a way to mock the
+// `BrowserTotpCaptureService` where jest would not load the file in the first place.
+jest.mock("qrcode-parser", () => {});
+
describe("ArchiveComponent", () => {
let component: ArchiveComponent;
diff --git a/apps/browser/src/vault/popup/settings/archive.component.ts b/apps/browser/src/vault/popup/settings/archive.component.ts
index 2a46ac0c46e..ecf091a7322 100644
--- a/apps/browser/src/vault/popup/settings/archive.component.ts
+++ b/apps/browser/src/vault/popup/settings/archive.component.ts
@@ -39,6 +39,7 @@ import {
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
+import { ROUTES_AFTER_EDIT_DELETION } from "../components/vault-v2/add-edit/add-edit-v2.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -120,7 +121,11 @@ export class ArchiveComponent {
}
await this.router.navigate(["/view-cipher"], {
- queryParams: { cipherId: cipher.id, type: cipher.type },
+ queryParams: {
+ cipherId: cipher.id,
+ type: cipher.type,
+ routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION.archive,
+ },
});
}
@@ -130,7 +135,11 @@ export class ArchiveComponent {
}
await this.router.navigate(["/edit-cipher"], {
- queryParams: { cipherId: cipher.id, type: cipher.type },
+ queryParams: {
+ cipherId: cipher.id,
+ type: cipher.type,
+ routeAfterDeletion: ROUTES_AFTER_EDIT_DELETION.archive,
+ },
});
}