From 139a5c1eb65070b7169149c557c5c05f6253505a Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:04:34 -0600 Subject: [PATCH 01/52] avoid setting width on body when extension is within a tab (#18499) --- .../src/platform/popup/layout/popup-size.service.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/platform/popup/layout/popup-size.service.ts b/apps/browser/src/platform/popup/layout/popup-size.service.ts index ff3f09d0d01..4c0c901270e 100644 --- a/apps/browser/src/platform/popup/layout/popup-size.service.ts +++ b/apps/browser/src/platform/popup/layout/popup-size.service.ts @@ -37,7 +37,7 @@ export class PopupSizeService { /** Begin listening for state changes */ async init() { this.width$.subscribe((width: PopupWidthOption) => { - PopupSizeService.setStyle(width); + void PopupSizeService.setStyle(width); localStorage.setItem(PopupSizeService.LocalStorageKey, width); }); } @@ -77,8 +77,9 @@ export class PopupSizeService { } } - private static setStyle(width: PopupWidthOption) { - if (!BrowserPopupUtils.inPopup(window)) { + private static async setStyle(width: PopupWidthOption) { + const isInTab = await BrowserPopupUtils.isInTab(); + if (!BrowserPopupUtils.inPopup(window) || isInTab) { return; } const pxWidth = PopupWidthOptions[width] ?? PopupWidthOptions.default; @@ -91,6 +92,6 @@ export class PopupSizeService { **/ static initBodyWidthFromLocalStorage() { const storedValue = localStorage.getItem(PopupSizeService.LocalStorageKey); - this.setStyle(storedValue as any); + void this.setStyle(storedValue as any); } } From 0270474c99bafd330dba5d5b30adf9616237abc4 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Thu, 22 Jan 2026 12:27:36 -0800 Subject: [PATCH 02/52] Move approve ssh request out of Platform (#18226) --- .../{platform => autofill}/components/approve-ssh-request.html | 0 .../{platform => autofill}/components/approve-ssh-request.ts | 0 apps/desktop/src/autofill/services/ssh-agent.service.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename apps/desktop/src/{platform => autofill}/components/approve-ssh-request.html (100%) rename apps/desktop/src/{platform => autofill}/components/approve-ssh-request.ts (100%) diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/autofill/components/approve-ssh-request.html similarity index 100% rename from apps/desktop/src/platform/components/approve-ssh-request.html rename to apps/desktop/src/autofill/components/approve-ssh-request.html diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/autofill/components/approve-ssh-request.ts similarity index 100% rename from apps/desktop/src/platform/components/approve-ssh-request.ts rename to apps/desktop/src/autofill/components/approve-ssh-request.ts diff --git a/apps/desktop/src/autofill/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts index 7e289720ec8..e3280f07ede 100644 --- a/apps/desktop/src/autofill/services/ssh-agent.service.ts +++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts @@ -32,8 +32,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherType } from "@bitwarden/common/vault/enums"; import { DialogService, ToastService } from "@bitwarden/components"; -import { ApproveSshRequestComponent } from "../../platform/components/approve-ssh-request"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; +import { ApproveSshRequestComponent } from "../components/approve-ssh-request"; import { SshAgentPromptType } from "../models/ssh-agent-setting"; @Injectable({ From 676b75902b0d422abcc58512ac72ab78b20fd110 Mon Sep 17 00:00:00 2001 From: brandonbiete Date: Thu, 22 Jan 2026 15:49:37 -0500 Subject: [PATCH 03/52] [BRE-1507] Remove PR process from workflow to allow direct pushes (#18504) --- .github/workflows/repository-management.yml | 46 ++------------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 79f3335313e..65607268cda 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -72,7 +72,6 @@ jobs: permissions: id-token: write contents: write - pull-requests: write steps: - name: Validate version input format @@ -111,8 +110,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} - permission-contents: write # for creating, committing to, and pushing new branches - permission-pull-requests: write # for generating pull requests + permission-contents: write # for committing and pushing to main (bypasses rulesets) - name: Check out branch uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -448,53 +446,15 @@ jobs: echo "No changes to commit!"; fi - - name: Create version bump branch - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - run: | - BRANCH_NAME="version-bump-$(date +%s)" - git checkout -b "$BRANCH_NAME" - echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV - - name: Commit version bumps with GPG signature if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: | git commit -m "Bumped client version(s)" -a - - name: Push version bump branch + - name: Push changes to main if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} run: | - git push --set-upstream origin "$BRANCH_NAME" - - - name: Create Pull Request for version bump - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - VERSION_BROWSER: ${{ steps.set-final-version-output.outputs.version_browser }} - VERSION_CLI: ${{ steps.set-final-version-output.outputs.version_cli }} - VERSION_DESKTOP: ${{ steps.set-final-version-output.outputs.version_desktop }} - VERSION_WEB: ${{ steps.set-final-version-output.outputs.version_web }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const versions = []; - if (process.env.VERSION_BROWSER) versions.push(`- Browser: ${process.env.VERSION_BROWSER}`); - if (process.env.VERSION_CLI) versions.push(`- CLI: ${process.env.VERSION_CLI}`); - if (process.env.VERSION_DESKTOP) versions.push(`- Desktop: ${process.env.VERSION_DESKTOP}`); - if (process.env.VERSION_WEB) versions.push(`- Web: ${process.env.VERSION_WEB}`); - - const body = versions.length > 0 - ? `Automated version bump:\n\n${versions.join('\n')}` - : 'Automated version bump'; - - const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: 'Bumped client version(s)', - body: body, - head: process.env.BRANCH_NAME, - base: context.ref.replace('refs/heads/', '') - }); - console.log(`Created PR #${pr.number}: ${pr.html_url}`); + git push cut_branch: name: Cut branch From 1baed4dea8992081cf3456bdd74c624766cf668c Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Thu, 22 Jan 2026 15:12:15 -0600 Subject: [PATCH 04/52] [PM-30470] Revert to using X11 on Linux desktop (#18465) --- apps/desktop/resources/linux-wrapper.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/resources/linux-wrapper.sh b/apps/desktop/resources/linux-wrapper.sh index 3c5d16c3a3d..e1cb69274d7 100644 --- a/apps/desktop/resources/linux-wrapper.sh +++ b/apps/desktop/resources/linux-wrapper.sh @@ -12,9 +12,13 @@ if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" fi +# A bug in Electron 39 (which now enables Wayland by default) causes a crash on +# systems using Wayland with hardware acceleration. Platform decided to +# configure Electron to use X11 (with an opt-out) until the upstream bug is +# fixed. The follow-up task is https://bitwarden.atlassian.net/browse/PM-31080. PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto" -if [ "$USE_X11" = "true" ]; then - PARAMS="" +if [ "$USE_X11" != "false" ]; then + PARAMS="--ozone-platform=x11" fi $APP_PATH/bitwarden-app $PARAMS "$@" From a9d8edc52ccdeec6d3d46df880ed8856344e40a2 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:20:53 -0600 Subject: [PATCH 05/52] [PM-28749] Desktop Transfer Items (#18410) * add transfer items prompt to desktop * add transfer service to vault v3 --- .../desktop/src/vault/app/vault-v3/vault.component.ts | 11 +++++++++++ .../desktop/src/vault/app/vault/vault-v2.component.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index c104f76ff2d..a64830c3b5d 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -79,6 +79,8 @@ import { VaultFilter, VaultFilterServiceAbstraction as VaultFilterService, RoutedVaultFilterBridgeService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; @@ -130,6 +132,7 @@ const BroadcasterSubscriptionId = "VaultComponent"; provide: COPY_CLICK_LISTENER, useExisting: VaultComponent, }, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { @@ -214,6 +217,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, private vaultFilterService: VaultFilterService, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -266,6 +270,11 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { if (this.vaultItemsComponent) { await this.vaultItemsComponent.refresh().catch(() => {}); } + if (this.activeUserId) { + void this.vaultItemTransferService.enforceOrganizationDataOwnership( + this.activeUserId, + ); + } break; case "modalShown": this.showingModal = true; @@ -372,6 +381,8 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { .subscribe((collections) => { this.filteredCollections = collections; }); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId); } ngOnDestroy() { diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index c1ab9d6f22a..efbdee97798 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -92,6 +92,8 @@ import { PasswordRepromptService, CipherFormComponent, ArchiveCipherUtilitiesService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { NavComponent } from "../../../app/layout/nav.component"; @@ -150,6 +152,7 @@ const BroadcasterSubscriptionId = "VaultComponent"; provide: COPY_CLICK_LISTENER, useExisting: VaultV2Component, }, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultV2Component @@ -264,6 +267,7 @@ export class VaultV2Component private policyService: PolicyService, private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, private masterPasswordService: MasterPasswordServiceAbstraction, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -317,6 +321,11 @@ export class VaultV2Component .catch(() => {}); await this.vaultFilterComponent.reloadOrganizations().catch(() => {}); } + if (this.activeUserId) { + void this.vaultItemTransferService.enforceOrganizationDataOwnership( + this.activeUserId, + ); + } break; case "modalShown": this.showingModal = true; @@ -420,6 +429,8 @@ export class VaultV2Component .subscribe((collections) => { this.allCollections = collections; }); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId); } ngOnDestroy() { From daacff888d5da3cb6eae4dedf258377440c44f7f Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Thu, 22 Jan 2026 16:55:35 -0500 Subject: [PATCH 06/52] [CL-1020] background color updates (#18417) * Adding new background colors * add sidenav color variables * fix admin console text color * update sidenav logos to use correct fill color * update nav logo focus ring color --- libs/assets/src/svg/svgs/admin-console.ts | 4 +-- libs/assets/src/svg/svgs/password-manager.ts | 4 +-- libs/assets/src/svg/svgs/provider-portal.ts | 4 +-- libs/assets/src/svg/svgs/secrets-manager.ts | 4 +-- libs/assets/src/svg/svgs/shield.ts | 4 +-- .../src/icon-button/icon-button.component.ts | 4 +-- .../src/navigation/nav-group.component.html | 2 +- .../src/navigation/nav-item.component.html | 16 ++++++------ .../src/navigation/nav-item.component.ts | 2 +- .../src/navigation/nav-logo.component.html | 2 +- .../src/navigation/side-nav.component.html | 12 ++++----- libs/components/src/tw-theme.css | 26 +++++++++++++++++++ libs/components/tailwind.config.base.js | 15 +++++++---- 13 files changed, 65 insertions(+), 34 deletions(-) diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 83c8cf9f0e1..3e8f47ec4a5 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const AdminConsoleLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 17b6f148be3..5b19562e022 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const PasswordManagerLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index 51c04e1553b..fad2ce6b864 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const ProviderPortalLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 27589e7e2f9..62b54174c55 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -2,13 +2,13 @@ import { svgIcon } from "../icon-service"; const SecretsManagerLogo = svgIcon` - + - + diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index 38d429604aa..af626a98e9d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -3,11 +3,11 @@ import { svgIcon } from "../icon-service"; const BitwardenShield = svgIcon` - + - + diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index c7eb28fc086..3b5e01132a2 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -71,9 +71,9 @@ const styles: Record = { primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing], "nav-contrast": [ - "!tw-text-alt2", + "!tw-text-fg-sidenav-text", "hover:!tw-bg-hover-contrast", - "focus-visible:before:tw-ring-text-alt2", + "focus-visible:before:tw-ring-border-focus", ...focusRing, ], }; diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index 1790fea179a..d305f89063e 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -19,7 +19,7 @@ From f57cb83d460d746fc7802a28ad4cb20fb5af408d Mon Sep 17 00:00:00 2001 From: Leslie Xiong Date: Fri, 23 Jan 2026 10:55:41 -0500 Subject: [PATCH 18/52] [BUG FIX]Desktop/Pm 31148/Pm 31149/Unexpected behaviors for Collections and Folders (#18506) * fixed collections still appearing if all orgs are suspended * fixed 'No folders' not displaying vault items * PR followup: - converted `allOrganizationsDisabled` to computed property - converted observables to signals --- .../vault-filter/vault-filter.component.html | 8 ++-- .../vault-filter/vault-filter.component.ts | 38 ++++++++++--------- .../src/vault/app/vault-v3/vault.component.ts | 2 + 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html index e0ae4687ed8..2110c545d9e 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/vault-filter.component.html @@ -6,11 +6,11 @@ - + @if (showCollectionsFilter()) { - @for (collection of (collections$ | async)?.children ?? []; track collection.node.id) { + @for (collection of collections()?.children ?? []; track collection.node.id) { } @@ -32,7 +32,7 @@ [appA11yTitle]="'folders' | i18n" [disableToggleOnClick]="true" > - @for (folder of (folders$ | async)?.children ?? []; track folder.node.id) { + @for (folder of folders()?.children ?? []; track folder.node.id) { >; - protected collections$: Observable>; - protected folders$: Observable>; - protected cipherTypes$: Observable>; + protected readonly organizations = toSignal(this.vaultFilterService.organizationTree$); + protected readonly collections = toSignal(this.vaultFilterService.collectionTree$); + protected readonly folders = toSignal(this.vaultFilterService.folderTree$); + protected readonly cipherTypes = toSignal(this.vaultFilterService.cipherTypeTree$); protected readonly showCollectionsFilter = computed(() => { - return this.organizations$ != null && !this.activeFilter()?.isMyVaultSelected; + return ( + this.organizations() != null && + !this.activeFilter()?.isMyVaultSelected && + !this.allOrganizationsDisabled() + ); + }); + + protected readonly allOrganizationsDisabled = computed(() => { + if (!this.organizations()) { + return false; + } + const orgs = this.organizations().children.filter((org) => org.node.id !== "MyVault"); + return orgs.length > 0 && orgs.every((org) => !org.node.enabled); }); private async setActivePolicies() { @@ -98,16 +107,9 @@ export class VaultFilterComponent implements OnInit { async ngOnInit(): Promise { this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - this.organizations$ = this.vaultFilterService.organizationTree$; - if ( - this.organizations$ != null && - (await firstValueFrom(this.organizations$)).children.length > 0 - ) { + if (this.organizations() != null && this.organizations().children.length > 0) { await this.setActivePolicies(); } - this.cipherTypes$ = this.vaultFilterService.cipherTypeTree$; - this.folders$ = this.vaultFilterService.folderTree$; - this.collections$ = this.vaultFilterService.collectionTree$; this.showArchiveVaultFilter = await firstValueFrom( this.cipherArchiveService.hasArchiveFlagEnabled$, diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index a64830c3b5d..455f9177c4d 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -805,6 +805,8 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { type: CipherViewLikeUtils.getType(cipher), // Normalize undefined organizationId to null for filter compatibility organizationId: cipher.organizationId ?? null, + // Normalize empty string folderId to null for filter compatibility + folderId: cipher.folderId ? cipher.folderId : null, // Explicitly include isDeleted and isArchived since they might be getters isDeleted: CipherViewLikeUtils.isDeleted(cipher), isArchived: CipherViewLikeUtils.isArchived(cipher), From 47b574dbc31a02a1a246ffd4f542e2727dd02746 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Fri, 23 Jan 2026 11:30:31 -0500 Subject: [PATCH 19/52] [PM-31072] If Archive Item is in Trash, remove Unarchive button (#18481) --- .../vault-item-dialog/vault-item-dialog.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index e4030a7ab18..059347709f0 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -87,7 +87,7 @@ @if (showActionButtons) {
@if ((userCanArchive$ | async) && !params.isAdminConsoleAction) { - @if (isCipherArchived) { + @if (isCipherArchived && !cipher?.isDeleted) { } diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html index fdfe8eb55ff..d1ee9d29ebd 100644 --- a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html @@ -1,5 +1,5 @@ diff --git a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html index 00cfa701529..268f5b912d1 100644 --- a/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html +++ b/libs/vault/src/components/new-cipher-menu/new-cipher-menu.component.html @@ -8,7 +8,7 @@ id="newItemDropdown" [appA11yTitle]="'new' | i18n" > - + {{ "new" | i18n }} From 6cf434cf114834fea58f4e0171fa75b4b4716f78 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Fri, 23 Jan 2026 11:57:55 -0500 Subject: [PATCH 21/52] [CL-841] landing layout component (#17969) * wip * wip * implement new control flow syntax * new landing helper components * add missing imports to header * create max width container * create landing card component * address claude feedback * fix center aligned text * ensure secondary content is centered for now * only render container when there is content * remove max width container * remove constructor init of variable * remove unnecessary styling * build styling into helper components * ensure content grows * ensure content takes allowed width * apply padding to elements instead of header and check for projected content * use Array.from to filter nodes * fix logo width * use has selector to apply actions styles, simplify heading padding * remove unneeded content projection * remove unneeded comment * use modern control flow for story * update max width classes to signal and remove switch * make logo input readonly * remove variables * remove object type * fix width types usage * add comments about component usage * fix broken variable reference * fix broken max width class usage * only appyly y padding to header actions --- .../anon-layout-wrapper.component.ts | 8 +- .../anon-layout/anon-layout.component.html | 93 ++-------- .../src/anon-layout/anon-layout.component.ts | 37 +--- libs/components/src/index.ts | 1 + libs/components/src/landing-layout/index.ts | 7 + .../landing-card.component.html | 5 + .../landing-layout/landing-card.component.ts | 33 ++++ .../landing-content.component.html | 8 + .../landing-content.component.ts | 63 +++++++ .../landing-footer.component.html | 3 + .../landing-footer.component.ts | 29 ++++ .../landing-header.component.html | 13 ++ .../landing-header.component.ts | 42 +++++ .../landing-hero.component.html | 28 +++ .../landing-layout/landing-hero.component.ts | 40 +++++ .../landing-layout.component.html | 25 +++ .../landing-layout.component.ts | 40 +++++ .../landing-layout/landing-layout.module.ts | 28 +++ .../landing-layout/landing-layout.stories.ts | 162 ++++++++++++++++++ 19 files changed, 551 insertions(+), 114 deletions(-) create mode 100644 libs/components/src/landing-layout/index.ts create mode 100644 libs/components/src/landing-layout/landing-card.component.html create mode 100644 libs/components/src/landing-layout/landing-card.component.ts create mode 100644 libs/components/src/landing-layout/landing-content.component.html create mode 100644 libs/components/src/landing-layout/landing-content.component.ts create mode 100644 libs/components/src/landing-layout/landing-footer.component.html create mode 100644 libs/components/src/landing-layout/landing-footer.component.ts create mode 100644 libs/components/src/landing-layout/landing-header.component.html create mode 100644 libs/components/src/landing-layout/landing-header.component.ts create mode 100644 libs/components/src/landing-layout/landing-hero.component.html create mode 100644 libs/components/src/landing-layout/landing-hero.component.ts create mode 100644 libs/components/src/landing-layout/landing-layout.component.html create mode 100644 libs/components/src/landing-layout/landing-layout.component.ts create mode 100644 libs/components/src/landing-layout/landing-layout.module.ts create mode 100644 libs/components/src/landing-layout/landing-layout.stories.ts diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 553da0c541b..84140a8953a 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -7,10 +7,10 @@ import { Icon } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; +import { LandingContentMaxWidthType } from "../landing-layout"; import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service"; -import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component"; - +import { AnonLayoutComponent } from "./anon-layout.component"; export interface AnonLayoutWrapperData { /** * The optional title of the page. @@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData { /** * Optional flag to set the max-width of the page. Defaults to 'md' if not provided. */ - maxWidth?: AnonLayoutMaxWidth; + maxWidth?: LandingContentMaxWidthType; /** * Hide the card that wraps the default content. Defaults to false. */ @@ -59,7 +59,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageSubtitle?: string | null; protected pageIcon: Icon | null = null; protected showReadonlyHostname?: boolean | null; - protected maxWidth?: AnonLayoutMaxWidth | null; + protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; protected hideBackgroundIllustration?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index 6bd72a25382..932ff10832c 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -1,76 +1,26 @@ -
- + + + + -
- @let iconInput = icon(); - - - -
- -
- - @if (title()) { - -

- {{ title() }} -

- -

- {{ title() }} -

- } - - @if (subtitle()) { -
{{ subtitle() }}
- } -
- -
+ + @if (hideCardWrapper()) {
} @else { - + - + } - -
+
+ +
+ @if (!hideFooter()) { -
+ @if (showReadonlyHostname()) {
{{ "accessing" | i18n }} {{ hostname }}
} @else { @@ -81,22 +31,9 @@
© {{ year }} Bitwarden Inc.
{{ version }}
} -
+ } - - @if (!hideBackgroundIllustration()) { -
- -
-
- -
- } -
+ diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index e6572a0c3c1..eded556cd53 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,23 +11,17 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { - BackgroundLeftIllustration, - BackgroundRightIllustration, - BitwardenLogo, - Icon, -} from "@bitwarden/assets/svg"; +import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { BaseCardComponent } from "../card"; import { IconModule } from "../icon"; +import { LandingContentMaxWidthType } from "../landing-layout"; +import { LandingLayoutModule } from "../landing-layout/landing-layout.module"; import { SharedModule } from "../shared"; import { TypographyModule } from "../typography"; -export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -39,7 +33,7 @@ export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; TypographyModule, SharedModule, RouterModule, - BaseCardComponent, + LandingLayoutModule, ], }) export class AnonLayoutComponent implements OnInit, OnChanges { @@ -49,9 +43,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges { return ["tw-h-full"]; } - readonly leftIllustration = BackgroundLeftIllustration; - readonly rightIllustration = BackgroundRightIllustration; - readonly title = input(); readonly subtitle = input(); readonly icon = model.required(); @@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { * * @default 'md' */ - readonly maxWidth = model("md"); + readonly maxWidth = model("md"); protected logo = BitwardenLogo; protected year: string; @@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges { protected hideYearAndVersion = false; - get maxWidthClass(): string { - const maxWidth = this.maxWidth(); - switch (maxWidth) { - case "md": - return "tw-max-w-md"; - case "lg": - return "tw-max-w-lg"; - case "xl": - return "tw-max-w-xl"; - case "2xl": - return "tw-max-w-2xl"; - case "3xl": - return "tw-max-w-3xl"; - case "4xl": - return "tw-max-w-4xl"; - } - } - constructor( private environmentService: EnvironmentService, private platformUtilsService: PlatformUtilsService, diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 23fb5beb456..9c4dadadd4b 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -25,6 +25,7 @@ export * from "./icon"; export * from "./icon-tile"; export * from "./input"; export * from "./item"; +export * from "./landing-layout"; export * from "./layout"; export * from "./link"; export * from "./menu"; diff --git a/libs/components/src/landing-layout/index.ts b/libs/components/src/landing-layout/index.ts new file mode 100644 index 00000000000..49b3d24631d --- /dev/null +++ b/libs/components/src/landing-layout/index.ts @@ -0,0 +1,7 @@ +export * from "./landing-layout.component"; +export * from "./landing-layout.module"; +export * from "./landing-card.component"; +export * from "./landing-content.component"; +export * from "./landing-footer.component"; +export * from "./landing-header.component"; +export * from "./landing-hero.component"; diff --git a/libs/components/src/landing-layout/landing-card.component.html b/libs/components/src/landing-layout/landing-card.component.html new file mode 100644 index 00000000000..bea783489bf --- /dev/null +++ b/libs/components/src/landing-layout/landing-card.component.html @@ -0,0 +1,5 @@ + + + diff --git a/libs/components/src/landing-layout/landing-card.component.ts b/libs/components/src/landing-layout/landing-card.component.ts new file mode 100644 index 00000000000..cea04f6f784 --- /dev/null +++ b/libs/components/src/landing-layout/landing-card.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +import { BaseCardComponent } from "../card"; + +/** + * Card component for landing pages that wraps content in a styled container. + * + * @remarks + * This component provides: + * - Card-based layout with consistent styling + * - Content projection for forms, text, or other content + * - Proper elevation and border styling + * + * Use this component inside `bit-landing-content` to wrap forms, content sections, + * or any content that should appear in a contained, elevated card. + * + * @example + * ```html + * + *
+ * + *
+ *
+ * ``` + */ +@Component({ + selector: "bit-landing-card", + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [BaseCardComponent], + templateUrl: "./landing-card.component.html", +}) +export class LandingCardComponent {} diff --git a/libs/components/src/landing-layout/landing-content.component.html b/libs/components/src/landing-layout/landing-content.component.html new file mode 100644 index 00000000000..a09db26e4e4 --- /dev/null +++ b/libs/components/src/landing-layout/landing-content.component.html @@ -0,0 +1,8 @@ +
+
+ + +
+
diff --git a/libs/components/src/landing-layout/landing-content.component.ts b/libs/components/src/landing-layout/landing-content.component.ts new file mode 100644 index 00000000000..940e4b01f53 --- /dev/null +++ b/libs/components/src/landing-layout/landing-content.component.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; + +export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const; + +export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number]; + +/** + * Main content container for landing pages with configurable max-width constraints. + * + * @remarks + * This component provides: + * - Centered content area with alternative background color + * - Configurable maximum width to control content readability + * - Content projection slots for hero section and main content + * - Responsive padding and layout + * + * Use this component inside `bit-landing-layout` to wrap your main page content. + * Optionally include a `bit-landing-hero` as the first child for consistent hero section styling. + * + * @example + * ```html + * + * + * + * + * + * + * ``` + */ +@Component({ + selector: "bit-landing-content", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-content.component.html", + host: { + class: "tw-grow tw-flex tw-flex-col", + }, +}) +export class LandingContentComponent { + /** + * Max width of the landing layout container. + * + * @default "md" + */ + readonly maxWidth = input("md"); + + private readonly maxWidthClassMap: Record = { + md: "tw-max-w-md", + lg: "tw-max-w-lg", + xl: "tw-max-w-xl", + "2xl": "tw-max-w-2xl", + "3xl": "tw-max-w-3xl", + "4xl": "tw-max-w-4xl", + }; + + readonly maxWidthClasses = computed(() => { + const maxWidthClass = this.maxWidthClassMap[this.maxWidth()]; + return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`; + }); +} diff --git a/libs/components/src/landing-layout/landing-footer.component.html b/libs/components/src/landing-layout/landing-footer.component.html new file mode 100644 index 00000000000..c0230a93171 --- /dev/null +++ b/libs/components/src/landing-layout/landing-footer.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/libs/components/src/landing-layout/landing-footer.component.ts b/libs/components/src/landing-layout/landing-footer.component.ts new file mode 100644 index 00000000000..f18199bd280 --- /dev/null +++ b/libs/components/src/landing-layout/landing-footer.component.ts @@ -0,0 +1,29 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; + +/** + * Footer component for landing pages. + * + * @remarks + * This component provides: + * - Content projection for custom footer content (e.g., links, copyright, legal) + * - Consistent footer positioning at the bottom of the page + * - Proper z-index to appear above background illustrations + * + * Use this component inside `bit-landing-layout` as the last child to position it at the bottom. + * + * @example + * ```html + * + *
+ * Privacy + * © 2024 Bitwarden + *
+ *
+ * ``` + */ +@Component({ + selector: "bit-landing-footer", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-footer.component.html", +}) +export class LandingFooterComponent {} diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html new file mode 100644 index 00000000000..ed6d34ef23b --- /dev/null +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -0,0 +1,13 @@ +
+ @if (!hideLogo()) { + + + + } +
+ +
+
diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts new file mode 100644 index 00000000000..eb5329e915d --- /dev/null +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { BitwardenLogo } from "@bitwarden/assets/svg"; + +import { IconModule } from "../icon"; +import { SharedModule } from "../shared"; + +/** + * Header component for landing pages with optional Bitwarden logo and header actions slot. + * + * @remarks + * This component provides: + * - Optional Bitwarden logo with link to home page (left-aligned) + * - Default content projection slot for header actions (right-aligned, auto-margin left) + * - Consistent header styling across landing pages + * - Responsive layout that adapts logo size + * + * Use this component inside `bit-landing-layout` as the first child to position it at the top. + * Content projected into this component will automatically align to the right side of the header. + * + * @example + * ```html + * + * + * + * + * ``` + */ +@Component({ + selector: "bit-landing-header", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-header.component.html", + imports: [RouterModule, IconModule, SharedModule], +}) +export class LandingHeaderComponent { + readonly hideLogo = input(false); + protected readonly logo = BitwardenLogo; +} diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html new file mode 100644 index 00000000000..dbce6a7c585 --- /dev/null +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -0,0 +1,28 @@ +@if (icon() || title() || subtitle()) { +
+ @if (icon()) { + + +
+ +
+ } + + @if (title()) { + +

+ {{ title() }} +

+ +

+ {{ title() }} +

+ } + + @if (subtitle()) { +
{{ subtitle() }}
+ } +
+} diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts new file mode 100644 index 00000000000..b29e9768efd --- /dev/null +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { Icon } from "@bitwarden/assets/svg"; + +import { IconModule } from "../icon"; +import { TypographyModule } from "../typography"; + +/** + * Hero section component for landing pages featuring an optional icon, title, and subtitle. + * + * @remarks + * This component provides: + * - Optional icon display (e.g., feature icons, status icons) + * - Large title text with consistent typography + * - Subtitle text for additional context + * - Centered layout with proper spacing + * + * Use this component as the first child inside `bit-landing-content` to create a prominent + * hero section that introduces the page's purpose. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: "bit-landing-hero", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-hero.component.html", + imports: [IconModule, TypographyModule], +}) +export class LandingHeroComponent { + readonly icon = input(null); + readonly title = input(); + readonly subtitle = input(); +} diff --git a/libs/components/src/landing-layout/landing-layout.component.html b/libs/components/src/landing-layout/landing-layout.component.html new file mode 100644 index 00000000000..1164f538116 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -0,0 +1,25 @@ +
+ +
+ +
+ @if (!hideBackgroundIllustration()) { +
+ +
+
+ +
+ } + +
diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts new file mode 100644 index 00000000000..520cca945d6 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -0,0 +1,40 @@ +import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core"; + +import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { IconModule } from "../icon"; + +/** + * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. + * + * @remarks + * This component serves as the outermost wrapper for landing pages and provides: + * - Full-screen layout that adapts to different client types (web, browser, desktop) + * - Optional decorative background illustrations in the bottom corners + * - Content projection slots for header, main content, and footer + * + * @example + * ```html + * + * ... + * ... + * ... + * + * ``` + */ +@Component({ + selector: "bit-landing-layout", + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./landing-layout.component.html", + imports: [IconModule], +}) +export class LandingLayoutComponent { + readonly hideBackgroundIllustration = input(false); + + protected readonly leftIllustration = BackgroundLeftIllustration; + protected readonly rightIllustration = BackgroundRightIllustration; + + private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService); + protected readonly clientType = this.platformUtilsService.getClientType(); +} diff --git a/libs/components/src/landing-layout/landing-layout.module.ts b/libs/components/src/landing-layout/landing-layout.module.ts new file mode 100644 index 00000000000..d225b8b35e1 --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from "@angular/core"; + +import { LandingCardComponent } from "./landing-card.component"; +import { LandingContentComponent } from "./landing-content.component"; +import { LandingFooterComponent } from "./landing-footer.component"; +import { LandingHeaderComponent } from "./landing-header.component"; +import { LandingHeroComponent } from "./landing-hero.component"; +import { LandingLayoutComponent } from "./landing-layout.component"; + +@NgModule({ + imports: [ + LandingLayoutComponent, + LandingHeaderComponent, + LandingHeroComponent, + LandingFooterComponent, + LandingContentComponent, + LandingCardComponent, + ], + exports: [ + LandingLayoutComponent, + LandingHeaderComponent, + LandingHeroComponent, + LandingFooterComponent, + LandingContentComponent, + LandingCardComponent, + ], +}) +export class LandingLayoutModule {} diff --git a/libs/components/src/landing-layout/landing-layout.stories.ts b/libs/components/src/landing-layout/landing-layout.stories.ts new file mode 100644 index 00000000000..7ea9598a64a --- /dev/null +++ b/libs/components/src/landing-layout/landing-layout.stories.ts @@ -0,0 +1,162 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { ButtonModule } from "../button"; + +import { LandingLayoutComponent } from "./landing-layout.component"; + +class MockPlatformUtilsService implements Partial { + getClientType = () => ClientType.Web; +} + +type StoryArgs = LandingLayoutComponent & { + contentLength: "normal" | "long" | "thin"; + includeHeader: boolean; + includeFooter: boolean; +}; + +export default { + title: "Component Library/Landing Layout", + component: LandingLayoutComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + ], + }), + ], + render: (args) => { + return { + props: args, + template: /*html*/ ` + + @if (includeHeader) { + +
+
+
Header Content
+
+
+
+ } + +
+ @switch (contentLength) { + @case ('thin') { +
+
Thin Content
+
+ } + @case ('long') { +
+
Long Content
+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
+
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
+
+ } + @default { +
+
Normal Content
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+
+ } + } +
+ + @if (includeFooter) { + +
+
Footer Content
+
+
+ } +
+ `, + }; + }, + + argTypes: { + hideBackgroundIllustration: { control: "boolean" }, + contentLength: { + control: "radio", + options: ["normal", "long", "thin"], + }, + includeHeader: { control: "boolean" }, + includeFooter: { control: "boolean" }, + }, + + args: { + hideBackgroundIllustration: false, + contentLength: "normal", + includeHeader: false, + includeFooter: false, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + contentLength: "normal", + }, +}; + +export const WithHeader: Story = { + args: { + includeHeader: true, + }, +}; + +export const WithFooter: Story = { + args: { + includeFooter: true, + }, +}; + +export const WithHeaderAndFooter: Story = { + args: { + includeHeader: true, + includeFooter: true, + }, +}; + +export const LongContent: Story = { + args: { + contentLength: "long", + includeHeader: true, + includeFooter: true, + }, +}; + +export const ThinContent: Story = { + args: { + contentLength: "thin", + includeHeader: true, + includeFooter: true, + }, +}; + +export const NoBackgroundIllustration: Story = { + args: { + hideBackgroundIllustration: true, + includeHeader: true, + includeFooter: true, + }, +}; + +export const MinimalState: Story = { + args: { + contentLength: "thin", + hideBackgroundIllustration: true, + includeHeader: false, + includeFooter: false, + }, +}; From bc8c925cd000a6d4665455ff4c171be2aff5a54a Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:09:59 -0600 Subject: [PATCH 22/52] [PM-27486] Remove feature flag PM25174_DisableType0Decryption (#18413) --- .../browser/src/background/main.background.ts | 1 - .../src/popup/services/init.service.ts | 5 --- .../service-container/service-container.ts | 1 - apps/desktop/src/app/services/init.service.ts | 3 -- apps/web/src/app/core/init.service.ts | 3 -- libs/common/src/enums/feature-flag.enum.ts | 2 - .../crypto/abstractions/encrypt.service.ts | 8 ---- .../encrypt.service.implementation.ts | 39 +++---------------- .../crypto/services/encrypt.service.spec.ts | 37 ++++++++++-------- 9 files changed, 26 insertions(+), 73 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9d551ec2622..58a7eb99ec6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1565,7 +1565,6 @@ export default class MainBackground { await this.sdkLoadService.loadAndInit(); // Only the "true" background should run migrations await this.migrationRunner.run(); - this.encryptService.init(this.configService); // This is here instead of in the InitService b/c we don't plan for // side effects to run in the Browser InitService. diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index f16d82d0810..24ff637c29b 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -2,8 +2,6 @@ import { inject, Inject, Injectable, DOCUMENT } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -30,8 +28,6 @@ export class InitService { private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, private readonly migrationRunner: MigrationRunner, - private configService: ConfigService, - private encryptService: EncryptService, @Inject(DOCUMENT) private document: Document, ) {} @@ -43,7 +39,6 @@ export class InitService { this.twoFactorService.init(); await this.viewCacheService.init(); await this.sizeService.init(); - this.encryptService.init(this.configService); const htmlEl = window.document.documentElement; this.themingService.applyThemeChangesTo(this.document); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index d98b5f0a861..bc3d3153b13 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -1058,7 +1058,6 @@ export class ServiceContainer { this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); - this.encryptService.init(this.configService); // If a user has a BW_SESSION key stored in their env (not process.env.BW_SESSION), // this should set the user key to unlock the vault on init. diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 17115825bf6..a6fd40cb998 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -54,7 +53,6 @@ export class InitService { private autotypeService: DesktopAutotypeService, private sdkLoadService: SdkLoadService, private biometricMessageHandlerService: BiometricMessageHandlerService, - private configService: ConfigService, @Inject(DOCUMENT) private document: Document, private readonly migrationRunner: MigrationRunner, ) {} @@ -65,7 +63,6 @@ export class InitService { await this.sshAgentService.init(); this.nativeMessagingService.init(); await this.migrationRunner.waitForCompletion(); // Desktop will run migrations in the main process - this.encryptService.init(this.configService); const accounts = await firstValueFrom(this.accountService.accounts$); const setUserKeyInMemoryPromises = []; diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 929f1489a61..9322d149e42 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { IpcService } from "@bitwarden/common/platform/ipc"; @@ -40,7 +39,6 @@ export class InitService { private ipcService: IpcService, private sdkLoadService: SdkLoadService, private taskService: TaskService, - private configService: ConfigService, private readonly migrationRunner: MigrationRunner, @Inject(DOCUMENT) private document: Document, ) {} @@ -49,7 +47,6 @@ export class InitService { return async () => { await this.sdkLoadService.loadAndInit(); await this.migrationRunner.run(); - this.encryptService.init(this.configService); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); if (activeAccount) { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 811f4e524ac..a6b0de1e2e5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -40,7 +40,6 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", - PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", DataRecoveryTool = "pm-28813-data-recovery-tool", @@ -150,7 +149,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, - [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 25e5f949b40..87af3852116 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -1,16 +1,8 @@ -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; - import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { EncString } from "../models/enc-string"; export abstract class EncryptService { - /** - * A temporary init method to make the encrypt service listen to feature-flag changes. - * This will be removed once the feature flag has been rolled out. - */ - abstract init(configService: ConfigService): void; - /** * Encrypts a string to an EncString * @param plainValue - The value to encrypt diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index a5da0c82382..b14211b5b72 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -1,9 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; @@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { - private disableType0Decryption = false; - constructor( protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, protected logMacFailures: boolean, ) {} - init(configService: ConfigService): void { - configService.serverConfig$.subscribe((newConfig) => { - if (newConfig != null) { - this.setDisableType0Decryption( - newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true, - ); - } - }); - } - - setDisableType0Decryption(disable: boolean): void { - this.disableType0Decryption = disable; - } - async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise { if (plainValue == null) { this.logService.warning( @@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) { + if (encString.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) { + if (encString.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService { } async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise { - if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) { + if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } await SdkLoadService.Ready; @@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } @@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService { if (wrappingKey == null) { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } @@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService { if (wrappingKey == null) { throw new Error("No wrappingKey provided for unwrapping."); } - if ( - this.disableType0Decryption && - keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64 - ) { + if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) { throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled."); } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 466f59da7c9..ac1f4d6ada0 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -163,7 +163,7 @@ describe("EncryptService", () => { describe("decryptString", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("encrypted_string"); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string"); const result = await encryptService.decryptString(encString, key); expect(result).toEqual("decrypted_string"); expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith( @@ -172,8 +172,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string"); await expect(encryptService.decryptString(encString, key)).rejects.toThrow( @@ -185,7 +184,7 @@ describe("EncryptService", () => { describe("decryptBytes", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("encrypted_bytes"); + const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes"); const result = await encryptService.decryptBytes(encString, key); expect(result).toEqual(new Uint8Array(3)); expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith( @@ -194,8 +193,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes"); await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow( @@ -216,8 +214,7 @@ describe("EncryptService", () => { ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encBuffer = EncArrayBuffer.fromParts( EncryptionType.AesCbc256_B64, @@ -234,7 +231,10 @@ describe("EncryptService", () => { describe("unwrapDecapsulationKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_decapsulation_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_decapsulation_key", + ); const result = await encryptService.unwrapDecapsulationKey(encString, key); expect(result).toEqual(new Uint8Array(4)); expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith( @@ -242,8 +242,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key"); await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow( @@ -267,7 +266,10 @@ describe("EncryptService", () => { describe("unwrapEncapsulationKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_encapsulation_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_encapsulation_key", + ); const result = await encryptService.unwrapEncapsulationKey(encString, key); expect(result).toEqual(new Uint8Array(5)); expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith( @@ -275,8 +277,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key"); await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow( @@ -300,7 +301,10 @@ describe("EncryptService", () => { describe("unwrapSymmetricKey", () => { it("is a proxy to PureCrypto", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); - const encString = new EncString("wrapped_symmetric_key"); + const encString = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "wrapped_symmetric_key", + ); const result = await encryptService.unwrapSymmetricKey(encString, key); expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith( @@ -308,8 +312,7 @@ describe("EncryptService", () => { key.toEncoded(), ); }); - it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => { - encryptService.setDisableType0Decryption(true); + it("throws if type is AesCbc256_B64", async () => { const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key"); await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow( From 3a5b31f7df8854b060376c50213239ec3d3a429d Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Fri, 23 Jan 2026 14:24:40 -0500 Subject: [PATCH 23/52] fix overlap of product switcher in side nav (#18533) --- libs/components/src/navigation/side-nav.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 6b53c525e3a..b70d650622a 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -27,7 +27,7 @@
@if (data.open) { From a59760f83b112cfc77e5a2bf550c25eff20a6a83 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:30:31 +0100 Subject: [PATCH 24/52] [PM-26049] Always store users auto unlock key on Cli (#18477) * Always store auto user key on CLI * update unit tests * prevent bad vault timeout state * Update libs/key-management/src/key.service.ts Co-authored-by: Bernd Schoolmann --------- Co-authored-by: Bernd Schoolmann --- .../vault-timeout-settings.service.spec.ts | 170 ++++++++++++++++-- .../vault-timeout-settings.service.ts | 29 +-- libs/key-management/src/key.service.spec.ts | 14 +- libs/key-management/src/key.service.ts | 16 +- 4 files changed, 197 insertions(+), 32 deletions(-) diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index ccb66a4dff4..3c391344f04 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => { }); describe("getVaultTimeoutByUserId$", () => { + beforeEach(() => { + // Return the input value unchanged + sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation( + async (timeout) => timeout, + ); + }); + it("should throw an error if no user id is provided", async () => { expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow( "User id required. Cannot get vault timeout.", @@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + defaultVaultTimeout, + ); expect(result).toBe(defaultVaultTimeout); }); @@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutNumberType.OnMinute; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue(of([])); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: custom", () => { @@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); expect(result).toBe(policyMinutes); }, ); @@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + vaultTimeout, + ); expect(result).toBe(vaultTimeout); }, ); @@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); expect(result).toBe(VaultTimeoutNumberType.Immediately); }); + + it("promotes policy minutes when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutNumberType.EightHours, + mockUserId, + ); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + policyMinutes, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: immediately", () => { @@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns immediately or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutNumberType.Immediately; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "immediately" } }] as unknown as Policy[]), ); @@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => { expect(result).toBe(expectedTimeout); }, ); + + it("promotes immediately when unavailable on client", async () => { + const promotedValue = VaultTimeoutNumberType.OnMinute; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "immediately" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onSystemLock", () => { @@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => { "when current timeout is %s, returns onLocked or promoted value", async (currentTimeout) => { const expectedTimeout = VaultTimeoutStringType.OnLocked; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); policyService.policiesByType$.mockReturnValue( of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), ); @@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes onLocked when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnLocked, + ); + expect(result).toBe(promotedValue); + }); }); describe("policy type: onAppRestart", () => { @@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnRestart, + ); expect(result).toBe(VaultTimeoutStringType.OnRestart); }); @@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); - }); - describe("policy type: never", () => { - it("when current timeout is never, returns never or promoted value", async () => { - const expectedTimeout = VaultTimeoutStringType.Never; - sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout); + it("promotes onRestart when unavailable on client", async () => { + const promotedValue = VaultTimeoutStringType.Never; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); policyService.policiesByType$.mockReturnValue( - of([{ data: { type: "never" } }] as unknown as Policy[]), + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), ); - await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + await stateProvider.setUserState( + VAULT_TIMEOUT, + VaultTimeoutStringType.OnLocked, + mockUserId, + ); const result = await firstValueFrom( vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( - VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, ); - expect(result).toBe(expectedTimeout); + expect(result).toBe(promotedValue); }); + }); + describe("policy type: never", () => { it.each([ + VaultTimeoutStringType.Never, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnIdle, @@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + currentTimeout, + ); expect(result).toBe(currentTimeout); }); + + it("promotes timeout when unavailable on client", async () => { + const determinedTimeout = VaultTimeoutStringType.Never; + const promotedValue = VaultTimeoutStringType.OnRestart; + + sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + determinedTimeout, + ); + expect(result).toBe(promotedValue); + }); }); }); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index b8bc859d11c..57e484fd767 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeout( currentVaultTimeout: VaultTimeout | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { + ): Promise { + const determinedTimeout = await this.determineVaultTimeoutInternal( + currentVaultTimeout, + maxSessionTimeoutPolicyData, + ); + + // Ensures the timeout is available on this client + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout); + } + + private async determineVaultTimeoutInternal( + currentVaultTimeout: VaultTimeout | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, + ): Promise { // if current vault timeout is null, apply the client specific default currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout; @@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA switch (maxSessionTimeoutPolicyData.type) { case "immediately": - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutNumberType.Immediately, - ); + return VaultTimeoutNumberType.Immediately; case "custom": case null: case undefined: @@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA currentVaultTimeout === VaultTimeoutStringType.OnIdle || currentVaultTimeout === VaultTimeoutStringType.OnSleep ) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.OnLocked, - ); + return VaultTimeoutStringType.OnLocked; } break; case "onAppRestart": @@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } break; case "never": - if (currentVaultTimeout === VaultTimeoutStringType.Never) { - return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( - VaultTimeoutStringType.Never, - ); - } + // Policy doesn't override user preference for "never" break; } return currentVaultTimeout; diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 9d96d7c09b1..85129aaedf4 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -259,7 +260,18 @@ describe("keyService", () => { }); }); - it("clears the Auto key if vault timeout is set to anything other than null", async () => { + it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => { + await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); + platformUtilService.getClientType.mockReturnValue(ClientType.Cli); + + await keyService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, { + userId: mockUserId, + }); + }); + + it("clears the Auto key if vault timeout is set to 10 minutes", async () => { await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId); await keyService.setUserKey(mockUserKey, mockUserId); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 4c749e9f6c4..d0b68229ea9 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -14,6 +14,7 @@ import { switchMap, } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key"; import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; @@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction { } protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) { - let shouldStoreKey = false; switch (keySuffix) { case KeySuffixOptions.Auto: { + // Cli has fixed Never vault timeout, and it should not be affected by a policy. + if (this.platformUtilService.getClientType() == ClientType.Cli) { + return true; + } + // TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between // the VaultTimeoutSettingsSvc and this service. // This should be fixed as part of the PM-7082 - Auto Key Service work. @@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction { .pipe(filter((timeout) => timeout != null)), ); - shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never; - break; + this.logService.debug( + `[KeyService] Should store auto key for vault timeout ${vaultTimeout}`, + ); + + return vaultTimeout == VaultTimeoutStringType.Never; } } - return shouldStoreKey; + return false; } protected async getKeyFromStorage( From cf2427848e8f396d3fd4491b87cb5148c7dd17f9 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Fri, 23 Jan 2026 13:36:54 -0600 Subject: [PATCH 25/52] [PM-30879] Huntress Integration (#18505) --- .../integrations/logo-huntress-siem.svg | 1 + apps/web/src/locales/en/messages.json | 9 + .../models/configuration/hec-configuration.ts | 10 +- .../models/integration-builder.spec.ts | 338 ++++++++++++++++++ .../models/integration-builder.ts | 15 +- .../configuration-template/hec-template.ts | 48 ++- .../organization-integration-service-type.ts | 1 + .../integration-card.component.spec.ts | 24 +- .../integration-card.component.ts | 328 ++++++++--------- .../connect-dialog-datadog.component.html | 2 +- .../connect-dialog-datadog.component.spec.ts | 5 +- .../connect-dialog-datadog.component.ts | 21 +- .../connect-dialog-hec.component.html | 2 +- .../connect-dialog-hec.component.spec.ts | 5 +- .../connect-dialog-hec.component.ts | 21 +- .../connect-dialog-huntress.component.html | 57 +++ .../connect-dialog-huntress.component.spec.ts | 206 +++++++++++ .../connect-dialog-huntress.component.ts | 114 ++++++ .../integration-dialog/index.ts | 2 + .../integration-dialog-result-status.ts | 11 + .../integrations.component.ts | 23 ++ libs/common/src/enums/feature-flag.enum.ts | 2 + 22 files changed, 1023 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/images/integrations/logo-huntress-siem.svg create mode 100644 bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts create mode 100644 bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts diff --git a/apps/web/src/images/integrations/logo-huntress-siem.svg b/apps/web/src/images/integrations/logo-huntress-siem.svg new file mode 100644 index 00000000000..06f2a3443c0 --- /dev/null +++ b/apps/web/src/images/integrations/logo-huntress-siem.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index db7332f4c2b..b15d60bf6b5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts index 275ff82e9bd..2f3a2634129 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/configuration/hec-configuration.ts @@ -3,15 +3,21 @@ import { OrganizationIntegrationServiceName } from "../organization-integration- export class HecConfiguration implements OrgIntegrationConfiguration { uri: string; - scheme = "Bearer"; + scheme: string; token: string; service?: string; bw_serviceName: OrganizationIntegrationServiceName; - constructor(uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName) { + constructor( + uri: string, + token: string, + bw_serviceName: OrganizationIntegrationServiceName, + scheme: string = "Bearer", + ) { this.uri = uri; this.token = token; this.bw_serviceName = bw_serviceName; + this.scheme = scheme; } toString(): string { diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts new file mode 100644 index 00000000000..6d7fad66f0e --- /dev/null +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.spec.ts @@ -0,0 +1,338 @@ +import { DatadogConfiguration } from "./configuration/datadog-configuration"; +import { HecConfiguration } from "./configuration/hec-configuration"; +import { OrgIntegrationBuilder } from "./integration-builder"; +import { DatadogTemplate } from "./integration-configuration-config/configuration-template/datadog-template"; +import { HecTemplate } from "./integration-configuration-config/configuration-template/hec-template"; +import { OrganizationIntegrationServiceName } from "./organization-integration-service-type"; +import { OrganizationIntegrationType } from "./organization-integration-type"; + +describe("OrgIntegrationBuilder", () => { + describe("buildHecConfiguration", () => { + const testUri = "https://hec.example.com:8088/services/collector"; + const testToken = "test-token"; + + it("should create HecConfiguration with correct values", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.Huntress, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBe(testUri); + expect((config as HecConfiguration).token).toBe(testToken); + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should use default Bearer scheme", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.Huntress, + ); + + expect((config as HecConfiguration).scheme).toBe("Bearer"); + }); + + it("should use custom scheme when provided", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.CrowdStrike, + "Splunk", + ); + + expect((config as HecConfiguration).scheme).toBe("Splunk"); + }); + + it("should work with CrowdStrike service name", () => { + const config = OrgIntegrationBuilder.buildHecConfiguration( + testUri, + testToken, + OrganizationIntegrationServiceName.CrowdStrike, + ); + + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.CrowdStrike); + }); + }); + + describe("buildHecTemplate", () => { + it("should create HecTemplate with correct values", () => { + const template = OrgIntegrationBuilder.buildHecTemplate( + "main", + OrganizationIntegrationServiceName.Huntress, + ); + + expect(template).toBeInstanceOf(HecTemplate); + expect((template as HecTemplate).index).toBe("main"); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should handle empty index", () => { + const template = OrgIntegrationBuilder.buildHecTemplate( + "", + OrganizationIntegrationServiceName.Huntress, + ); + + expect((template as HecTemplate).index).toBe(""); + }); + }); + + describe("buildDataDogConfiguration", () => { + const testUri = "https://http-intake.logs.datadoghq.com/api/v2/logs"; + const testApiKey = "test-api-key"; + + it("should create DatadogConfiguration with correct values", () => { + const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey); + + expect(config).toBeInstanceOf(DatadogConfiguration); + expect((config as DatadogConfiguration).uri).toBe(testUri); + expect((config as DatadogConfiguration).apiKey).toBe(testApiKey); + }); + + it("should always use Datadog service name", () => { + const config = OrgIntegrationBuilder.buildDataDogConfiguration(testUri, testApiKey); + + expect(config.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("buildDataDogTemplate", () => { + it("should create DatadogTemplate with correct service name", () => { + const template = OrgIntegrationBuilder.buildDataDogTemplate( + OrganizationIntegrationServiceName.Datadog, + ); + + expect(template).toBeInstanceOf(DatadogTemplate); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("buildConfiguration", () => { + describe("HEC type", () => { + it("should build HecConfiguration from JSON string", () => { + const json = JSON.stringify({ + Uri: "https://hec.example.com", + Token: "test-token", + Scheme: "Bearer", + bw_serviceName: OrganizationIntegrationServiceName.Huntress, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBe("https://hec.example.com"); + expect((config as HecConfiguration).token).toBe("test-token"); + expect((config as HecConfiguration).scheme).toBe("Bearer"); + }); + + it("should normalize PascalCase properties to camelCase", () => { + const json = JSON.stringify({ + Uri: "https://hec.example.com", + Token: "test-token", + Scheme: "Splunk", + bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect((config as HecConfiguration).uri).toBe("https://hec.example.com"); + expect((config as HecConfiguration).token).toBe("test-token"); + expect((config as HecConfiguration).scheme).toBe("Splunk"); + }); + }); + + describe("Datadog type", () => { + it("should build DatadogConfiguration from JSON string", () => { + const json = JSON.stringify({ + Uri: "https://datadoghq.com/api", + ApiKey: "dd-api-key", + bw_serviceName: OrganizationIntegrationServiceName.Datadog, + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(config).toBeInstanceOf(DatadogConfiguration); + expect((config as DatadogConfiguration).uri).toBe("https://datadoghq.com/api"); + expect((config as DatadogConfiguration).apiKey).toBe("dd-api-key"); + }); + }); + + describe("error handling", () => { + it("should throw for unsupported integration type", () => { + const json = JSON.stringify({ uri: "test" }); + + expect(() => + OrgIntegrationBuilder.buildConfiguration(999 as OrganizationIntegrationType, json), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should throw for invalid JSON", () => { + expect(() => + OrgIntegrationBuilder.buildConfiguration(OrganizationIntegrationType.Hec, "invalid-json"), + ).toThrow("Invalid integration configuration: JSON parse error"); + }); + + it("should handle empty JSON string by using empty object", () => { + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + "", + ); + + expect(config).toBeInstanceOf(HecConfiguration); + }); + + it("should handle undefined values in JSON", () => { + const json = JSON.stringify({}); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + expect((config as HecConfiguration).uri).toBeUndefined(); + }); + }); + }); + + describe("buildTemplate", () => { + describe("HEC type", () => { + it("should build HecTemplate from JSON string", () => { + const json = JSON.stringify({ + index: "main", + bw_serviceName: OrganizationIntegrationServiceName.Huntress, + }); + + const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json); + + expect(template).toBeInstanceOf(HecTemplate); + expect((template as HecTemplate).index).toBe("main"); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Huntress); + }); + + it("should normalize PascalCase properties", () => { + const json = JSON.stringify({ + Index: "security", + bw_serviceName: OrganizationIntegrationServiceName.CrowdStrike, + }); + + const template = OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, json); + + expect((template as HecTemplate).index).toBe("security"); + }); + }); + + describe("Datadog type", () => { + it("should build DatadogTemplate from JSON string", () => { + const json = JSON.stringify({ + bw_serviceName: OrganizationIntegrationServiceName.Datadog, + }); + + const template = OrgIntegrationBuilder.buildTemplate( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(template).toBeInstanceOf(DatadogTemplate); + expect(template.bw_serviceName).toBe(OrganizationIntegrationServiceName.Datadog); + }); + }); + + describe("error handling", () => { + it("should throw for unsupported integration type", () => { + const json = JSON.stringify({ index: "test" }); + + expect(() => + OrgIntegrationBuilder.buildTemplate(999 as OrganizationIntegrationType, json), + ).toThrow("Unsupported integration type: 999"); + }); + + it("should throw for invalid JSON", () => { + expect(() => + OrgIntegrationBuilder.buildTemplate(OrganizationIntegrationType.Hec, "invalid-json"), + ).toThrow("Invalid integration configuration: JSON parse error"); + }); + }); + }); + + describe("property case normalization", () => { + it("should convert first character to lowercase", () => { + const json = JSON.stringify({ + Uri: "https://example.com", + Token: "token", + Scheme: "Bearer", + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + // Verify the properties were normalized (accessed via camelCase) + expect((config as HecConfiguration).uri).toBe("https://example.com"); + expect((config as HecConfiguration).token).toBe("token"); + }); + + it("should handle nested objects", () => { + // Using Datadog type which has nested enrichment_details + const json = JSON.stringify({ + Uri: "https://datadoghq.com", + ApiKey: "key", + NestedObject: { + InnerProperty: "value", + }, + }); + + // This tests that nested properties are also normalized + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Datadog, + json, + ); + + expect(config).toBeInstanceOf(DatadogConfiguration); + }); + + it("should handle arrays", () => { + const json = JSON.stringify({ + Uri: "https://example.com", + Token: "token", + Items: [{ Name: "item1" }, { Name: "item2" }], + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect(config).toBeInstanceOf(HecConfiguration); + }); + + it("should preserve properties that start with lowercase", () => { + const json = JSON.stringify({ + uri: "https://example.com", + token: "token", + bw_serviceName: "Huntress", + }); + + const config = OrgIntegrationBuilder.buildConfiguration( + OrganizationIntegrationType.Hec, + json, + ); + + expect((config as HecConfiguration).uri).toBe("https://example.com"); + expect((config as HecConfiguration).token).toBe("token"); + }); + }); +}); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts index db682d58db4..e95f1f0ddf6 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-builder.ts @@ -21,6 +21,11 @@ export interface OrgIntegrationTemplate { toString(): string; } +export const Schemas = { + Bearer: "Bearer", + Splunk: "Splunk", +} as const; + /** * Builder class for creating organization integration configurations and templates */ @@ -29,8 +34,9 @@ export class OrgIntegrationBuilder { uri: string, token: string, bw_serviceName: OrganizationIntegrationServiceName, + scheme: string = Schemas.Bearer, ): OrgIntegrationConfiguration { - return new HecConfiguration(uri, token, bw_serviceName); + return new HecConfiguration(uri, token, bw_serviceName, scheme); } static buildHecTemplate( @@ -57,7 +63,12 @@ export class OrgIntegrationBuilder { switch (type) { case OrganizationIntegrationType.Hec: { const hecConfig = this.convertToJson(configuration); - return this.buildHecConfiguration(hecConfig.uri, hecConfig.token, hecConfig.bw_serviceName); + return this.buildHecConfiguration( + hecConfig.uri, + hecConfig.token, + hecConfig.bw_serviceName, + hecConfig.scheme, + ); } case OrganizationIntegrationType.Datadog: { const datadogConfig = this.convertToJson(configuration); diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts index 27d71f29e59..3c0cf3b9b35 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/integration-configuration-config/configuration-template/hec-template.ts @@ -2,8 +2,6 @@ import { OrgIntegrationTemplate } from "../../integration-builder"; import { OrganizationIntegrationServiceName } from "../../organization-integration-service-type"; export class HecTemplate implements OrgIntegrationTemplate { - event = "#EventMessage#"; - source = "Bitwarden"; index: string; bw_serviceName: OrganizationIntegrationServiceName; @@ -12,12 +10,46 @@ export class HecTemplate implements OrgIntegrationTemplate { this.bw_serviceName = service; } - toString(): string { - return JSON.stringify({ - Event: this.event, - Source: this.source, - Index: this.index, + private toJSON() { + const template: Record = { bw_serviceName: this.bw_serviceName, - }); + source: "bitwarden", + service: "event-logs", + event: { + object: "event", + type: "#Type#", + itemId: "#CipherId#", + collectionId: "#CollectionId#", + groupId: "#GroupId#", + policyId: "#PolicyId#", + memberId: "#UserId#", + actingUserId: "#ActingUserId#", + installationId: "#InstallationId#", + date: "#DateIso8601#", + device: "#DeviceType#", + ipAddress: "#IpAddress#", + secretId: "#SecretId#", + projectId: "#ProjectId#", + serviceAccountId: "#ServiceAccountId#", + actingUserName: "#ActingUserName#", + actingUserEmail: "#ActingUserEmail#", + actingUserType: "#ActingUserType#", + userName: "#UserName#", + userEmail: "#UserEmail#", + userType: "#UserType#", + groupName: "#GroupName#", + }, + }; + + // Only include index if it's provided + if (this.index && this.index.trim() !== "") { + template.index = this.index; + } + + return template; + } + + toString(): string { + return JSON.stringify(this.toJSON()); } } diff --git a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts index 9634ad7249a..5c4b851e7b1 100644 --- a/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts +++ b/bitwarden_license/bit-common/src/dirt/organization-integrations/models/organization-integration-service-type.ts @@ -1,6 +1,7 @@ export const OrganizationIntegrationServiceName = Object.freeze({ CrowdStrike: "CrowdStrike", Datadog: "Datadog", + Huntress: "Huntress", } as const); export type OrganizationIntegrationServiceName = diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts index 37bd504643c..928bb9488b3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.spec.ts @@ -16,13 +16,15 @@ import { DialogService, ToastService } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; -import { HecConnectDialogResultStatus, openHecConnectDialog } from "../integration-dialog"; +import { IntegrationDialogResultStatus, openHecConnectDialog } from "../integration-dialog"; import { IntegrationCardComponent } from "./integration-card.component"; jest.mock("../integration-dialog", () => ({ openHecConnectDialog: jest.fn(), - HecConnectDialogResultStatus: { Edited: "edit", Delete: "delete" }, + openDatadogConnectDialog: jest.fn(), + openHuntressConnectDialog: jest.fn(), + IntegrationDialogResultStatus: { Edited: "edit", Delete: "delete" }, })); describe("IntegrationCardComponent", () => { @@ -276,7 +278,7 @@ describe("IntegrationCardComponent", () => { it("should call updateHec if isUpdateAvailable is true", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -317,7 +319,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -354,7 +356,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -382,7 +384,7 @@ describe("IntegrationCardComponent", () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -404,7 +406,7 @@ describe("IntegrationCardComponent", () => { it("should show toast on error while saving", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -427,7 +429,7 @@ describe("IntegrationCardComponent", () => { it("should show mustBeOwner toast on error while inserting data", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -450,7 +452,7 @@ describe("IntegrationCardComponent", () => { it("should show mustBeOwner toast on error while updating data", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, url: "test-url", bearerToken: "token", index: "index", @@ -472,7 +474,7 @@ describe("IntegrationCardComponent", () => { it("should show toast on error while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", @@ -495,7 +497,7 @@ describe("IntegrationCardComponent", () => { it("should show mustbeOwner toast on 404 while deleting", async () => { (openHecConnectDialog as jest.Mock).mockReturnValue({ closed: of({ - success: HecConnectDialogResultStatus.Delete, + success: IntegrationDialogResultStatus.Delete, url: "test-url", bearerToken: "token", index: "index", diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts index 8026e14c2fc..f423a9b86d9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-card/integration-card.component.ts @@ -12,7 +12,12 @@ import { Observable, Subject, combineLatest, lastValueFrom, takeUntil } from "rx import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; -import { OrgIntegrationBuilder } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; +import { + OrgIntegrationBuilder, + OrgIntegrationConfiguration, + OrgIntegrationTemplate, + Schemas, +} from "@bitwarden/bit-common/dirt/organization-integrations/models/integration-builder"; import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type"; import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type"; import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service"; @@ -23,7 +28,6 @@ import { OrganizationId } from "@bitwarden/common/types/guid"; import { BaseCardComponent, CardContentComponent, - DialogRef, DialogService, ToastService, } from "@bitwarden/components"; @@ -32,10 +36,11 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { HecConnectDialogResult, DatadogConnectDialogResult, - HecConnectDialogResultStatus, - DatadogConnectDialogResultStatus, + HuntressConnectDialogResult, + IntegrationDialogResultStatus, openDatadogConnectDialog, openHecConnectDialog, + openHuntressConnectDialog, } from "../integration-dialog/index"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -164,14 +169,12 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async setupConnection() { - let dialog: DialogRef; - if (this.integrationSettings?.integrationType === null) { return; } if (this.integrationSettings?.integrationType === OrganizationIntegrationType.Datadog) { - dialog = openDatadogConnectDialog(this.dialogService, { + const dialog = openDatadogConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, }, @@ -179,37 +182,29 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const result = await lastValueFrom(dialog.closed); - // the dialog was cancelled - if (!result || !result.success) { - return; - } + await this.handleIntegrationDialogResult( + result, + () => this.deleteDatadog(), + (res) => this.saveDatadog(res), + ); + } else if (this.integrationSettings.name === OrganizationIntegrationServiceName.Huntress) { + // Huntress uses HEC protocol but has its own dialog + const dialog = openHuntressConnectDialog(this.dialogService, { + data: { + settings: this.integrationSettings, + }, + }); - try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteDatadog(); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToDeleteIntegration"), - }); - } + const result = await lastValueFrom(dialog.closed); - try { - if (result.success === DatadogConnectDialogResultStatus.Edited) { - await this.saveDatadog(result as DatadogConnectDialogResult); - } - } catch { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("failedToSaveIntegration"), - }); - } + await this.handleIntegrationDialogResult( + result, + () => this.deleteHuntress(), + (res) => this.saveHuntress(res), + ); } else { // invoke the dialog to connect the integration - dialog = openHecConnectDialog(this.dialogService, { + const dialog = openHecConnectDialog(this.dialogService, { data: { settings: this.integrationSettings, }, @@ -217,15 +212,113 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { const result = await lastValueFrom(dialog.closed); - // the dialog was cancelled - if (!result || !result.success) { - return; + await this.handleIntegrationDialogResult( + result, + () => this.deleteHec(), + (res) => this.saveHec(res), + ); + } + } + + /** + * Generic save method + */ + private async saveIntegration( + integrationType: OrganizationIntegrationType, + config: OrgIntegrationConfiguration, + template: OrgIntegrationTemplate, + ): Promise { + let response = { mustBeOwner: false, success: false }; + + if (this.isUpdateAvailable) { + // retrieve org integration and configuration ids + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); } + // update existing integration and configuration + response = await this.organizationIntegrationService.update( + this.organizationId, + orgIntegrationId, + integrationType, + orgIntegrationConfigurationId, + config, + template, + ); + } else { + // create new integration and configuration + response = await this.organizationIntegrationService.save( + this.organizationId, + integrationType, + config, + template, + ); + } + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + /** + * Generic delete method + */ + private async deleteIntegration(): Promise { + const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; + const orgIntegrationConfigurationId = + this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + + if (!orgIntegrationId || !orgIntegrationConfigurationId) { + throw Error("Organization Integration ID or Configuration ID is missing"); + } + + const response = await this.organizationIntegrationService.delete( + this.organizationId, + orgIntegrationId, + orgIntegrationConfigurationId, + ); + + if (response.mustBeOwner) { + this.showMustBeOwnerToast(); + return; + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("success"), + }); + } + + /** + * Generic dialog result handler + * Handles both delete and edit actions with proper error handling + */ + private async handleIntegrationDialogResult( + result: T | undefined, + deleteCallback: () => Promise, + saveCallback: (result: T) => Promise, + ): Promise { + // User cancelled the dialog or closed it without saving + if (!result || !result.success) { + return; + } + + // Handle delete action + if (result.success === IntegrationDialogResultStatus.Delete) { try { - if (result.success === HecConnectDialogResultStatus.Delete) { - await this.deleteHec(); - } + await deleteCallback(); } catch { this.toastService.showToast({ variant: "error", @@ -233,11 +326,13 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { message: this.i18nService.t("failedToDeleteIntegration"), }); } + return; + } + // Handle edit/save action + if (result.success === IntegrationDialogResultStatus.Edited) { try { - if (result.success === HecConnectDialogResultStatus.Edited) { - await this.saveHec(result as HecConnectDialogResult); - } + await saveCallback(result); } catch { this.toastService.showToast({ variant: "error", @@ -249,8 +344,6 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { } async saveHec(result: HecConnectDialogResult) { - let response = { mustBeOwner: false, success: false }; - const config = OrgIntegrationBuilder.buildHecConfiguration( result.url, result.bearerToken, @@ -261,148 +354,45 @@ export class IntegrationCardComponent implements AfterViewInit, OnDestroy { this.integrationSettings.name as OrganizationIntegrationServiceName, ); - if (this.isUpdateAvailable) { - // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - // update existing integration and configuration - response = await this.organizationIntegrationService.update( - this.organizationId, - orgIntegrationId, - OrganizationIntegrationType.Hec, - orgIntegrationConfigurationId, - config, - template, - ); - } else { - // create new integration and configuration - response = await this.organizationIntegrationService.save( - this.organizationId, - OrganizationIntegrationType.Hec, - config, - template, - ); - } - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); } async deleteHec() { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; + await this.deleteIntegration(); + } - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - const response = await this.organizationIntegrationService.delete( - this.organizationId, - orgIntegrationId, - orgIntegrationConfigurationId, + async saveHuntress(result: HuntressConnectDialogResult) { + // Huntress uses "Splunk" scheme for HEC protocol compatibility + const config = OrgIntegrationBuilder.buildHecConfiguration( + result.url, + result.token, + OrganizationIntegrationServiceName.Huntress, + Schemas.Splunk, + ); + // Huntress SIEM doesn't require the index field + const template = OrgIntegrationBuilder.buildHecTemplate( + "", + OrganizationIntegrationServiceName.Huntress, ); - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } + await this.saveIntegration(OrganizationIntegrationType.Hec, config, template); + } - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + async deleteHuntress() { + await this.deleteIntegration(); } async saveDatadog(result: DatadogConnectDialogResult) { - let response = { mustBeOwner: false, success: false }; - const config = OrgIntegrationBuilder.buildDataDogConfiguration(result.url, result.apiKey); const template = OrgIntegrationBuilder.buildDataDogTemplate( this.integrationSettings.name as OrganizationIntegrationServiceName, ); - if (this.isUpdateAvailable) { - // retrieve org integration and configuration ids - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - // update existing integration and configuration - response = await this.organizationIntegrationService.update( - this.organizationId, - orgIntegrationId, - OrganizationIntegrationType.Datadog, - orgIntegrationConfigurationId, - config, - template, - ); - } else { - // create new integration and configuration - response = await this.organizationIntegrationService.save( - this.organizationId, - OrganizationIntegrationType.Datadog, - config, - template, - ); - } - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.saveIntegration(OrganizationIntegrationType.Datadog, config, template); } async deleteDatadog() { - const orgIntegrationId = this.integrationSettings.organizationIntegration?.id; - const orgIntegrationConfigurationId = - this.integrationSettings.organizationIntegration?.integrationConfiguration[0]?.id; - - if (!orgIntegrationId || !orgIntegrationConfigurationId) { - throw Error("Organization Integration ID or Configuration ID is missing"); - } - - const response = await this.organizationIntegrationService.delete( - this.organizationId, - orgIntegrationId, - orgIntegrationConfigurationId, - ); - - if (response.mustBeOwner) { - this.showMustBeOwnerToast(); - return; - } - - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("success"), - }); + await this.deleteIntegration(); } private showMustBeOwnerToast() { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html index ddc108201b0..523cbc66d56 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.html @@ -23,7 +23,7 @@ {{ "apiKey" | i18n }} - + {{ "apiKey" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts index 7298087e7e4..76fc8144309 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.spec.ts @@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/ import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + import { ConnectDatadogDialogComponent, DatadogConnectDialogParams, DatadogConnectDialogResult, - DatadogConnectDialogResultStatus, openDatadogConnectDialog, } from "./connect-dialog-datadog.component"; @@ -149,7 +150,7 @@ describe("ConnectDialogDatadogComponent", () => { url: "https://test.com", apiKey: "token", service: "Test Service", - success: DatadogConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts index 47760c6311a..cedc8e5d3e3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-datadog.component.ts @@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + export type DatadogConnectDialogParams = { settings: Integration; }; @@ -16,17 +21,9 @@ export interface DatadogConnectDialogResult { url: string; apiKey: string; service: string; - success: DatadogConnectDialogResultStatusType | null; + success: IntegrationDialogResultStatusType | null; } -export const DatadogConnectDialogResultStatus = { - Edited: "edit", - Delete: "delete", -} as const; - -export type DatadogConnectDialogResultStatusType = - (typeof DatadogConnectDialogResultStatus)[keyof typeof DatadogConnectDialogResultStatus]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -78,7 +75,7 @@ export class ConnectDatadogDialogComponent implements OnInit { this.formGroup.markAllAsTouched(); return; } - const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Edited); + const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Edited); this.dialogRef.close(result); @@ -95,13 +92,13 @@ export class ConnectDatadogDialogComponent implements OnInit { }); if (confirmed) { - const result = this.getDatadogConnectDialogResult(DatadogConnectDialogResultStatus.Delete); + const result = this.getDatadogConnectDialogResult(IntegrationDialogResultStatus.Delete); this.dialogRef.close(result); } }; private getDatadogConnectDialogResult( - status: DatadogConnectDialogResultStatusType, + status: IntegrationDialogResultStatusType, ): DatadogConnectDialogResult { const formJson = this.formGroup.getRawValue(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html index 0dad1621440..1cafd7c3211 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.html @@ -23,7 +23,7 @@ {{ "bearerToken" | i18n }} - + {{ "apiKey" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts index 9f640ebbcc7..c337f2872d6 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.spec.ts @@ -10,11 +10,12 @@ import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/ import { I18nPipe } from "@bitwarden/ui-common"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + import { ConnectHecDialogComponent, HecConnectDialogParams, HecConnectDialogResult, - HecConnectDialogResultStatus, openHecConnectDialog, } from "./connect-dialog-hec.component"; @@ -155,7 +156,7 @@ describe("ConnectDialogHecComponent", () => { bearerToken: "token", index: "1", service: "Test Service", - success: HecConnectDialogResultStatus.Edited, + success: IntegrationDialogResultStatus.Edited, }); }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts index 3612f2c76cb..3d38cfd1f79 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-hec.component.ts @@ -7,6 +7,11 @@ import { HecTemplate } from "@bitwarden/bit-common/dirt/organization-integration import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + export type HecConnectDialogParams = { settings: Integration; }; @@ -17,17 +22,9 @@ export interface HecConnectDialogResult { bearerToken: string; index: string; service: string; - success: HecConnectDialogResultStatusType | null; + success: IntegrationDialogResultStatusType | null; } -export const HecConnectDialogResultStatus = { - Edited: "edit", - Delete: "delete", -} as const; - -export type HecConnectDialogResultStatusType = - (typeof HecConnectDialogResultStatus)[keyof typeof HecConnectDialogResultStatus]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -81,7 +78,7 @@ export class ConnectHecDialogComponent implements OnInit { this.formGroup.markAllAsTouched(); return; } - const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Edited); + const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Edited); this.dialogRef.close(result); @@ -98,13 +95,13 @@ export class ConnectHecDialogComponent implements OnInit { }); if (confirmed) { - const result = this.getHecConnectDialogResult(HecConnectDialogResultStatus.Delete); + const result = this.getHecConnectDialogResult(IntegrationDialogResultStatus.Delete); this.dialogRef.close(result); } }; private getHecConnectDialogResult( - status: HecConnectDialogResultStatusType, + status: IntegrationDialogResultStatusType, ): HecConnectDialogResult { const formJson = this.formGroup.getRawValue(); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html new file mode 100644 index 00000000000..7c2894ff8b1 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.html @@ -0,0 +1,57 @@ +
+ + + {{ "connectIntegrationButtonDesc" | i18n: connectInfo.settings.name }} + +
+ @if (loading) { + + + + } + @if (!loading) { + + + {{ "httpEventCollectorUrl" | i18n }} + + + + + {{ "httpEventCollectorToken" | i18n }} + + + + } +
+ + + + + @if (canDelete) { +
+ +
+ } +
+
+
diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts new file mode 100644 index 00000000000..9c5dc58a762 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.spec.ts @@ -0,0 +1,206 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { IntegrationType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { IntegrationDialogResultStatus } from "../integration-dialog-result-status"; + +import { + ConnectHuntressDialogComponent, + HuntressConnectDialogParams, + HuntressConnectDialogResult, + openHuntressConnectDialog, +} from "./connect-dialog-huntress.component"; + +beforeAll(() => { + // Mock element.animate for jsdom + // the animate function is not available in jsdom, so we provide a mock implementation + // This is necessary for tests that rely on animations + // This mock does not perform any actual animations, it just provides a structure that allows tests + // to run without throwing errors related to missing animate function + if (!HTMLElement.prototype.animate) { + HTMLElement.prototype.animate = function () { + return { + play: () => {}, + pause: () => {}, + finish: () => {}, + cancel: () => {}, + reverse: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + onfinish: null, + oncancel: null, + startTime: 0, + currentTime: 0, + playbackRate: 1, + playState: "idle", + replaceState: "active", + effect: null, + finished: Promise.resolve(), + id: "", + remove: () => {}, + timeline: null, + ready: Promise.resolve(), + } as unknown as Animation; + }; + } +}); + +describe("ConnectHuntressDialogComponent", () => { + let component: ConnectHuntressDialogComponent; + let fixture: ComponentFixture; + let dialogRefMock = mock>(); + const mockI18nService = mock(); + + const integrationMock: Integration = { + name: "Huntress", + image: "test-image.png", + linkURL: "https://example.com", + imageDarkMode: "test-image-dark.png", + newBadgeExpiration: "2024-12-31", + description: "Test Description", + canSetupConnection: true, + type: IntegrationType.EVENT, + } as Integration; + + const connectInfo: HuntressConnectDialogParams = { + settings: integrationMock, + }; + + beforeEach(async () => { + dialogRefMock = mock>(); + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, SharedModule, BrowserAnimationsModule], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: connectInfo }, + { provide: DialogRef, useValue: dialogRefMock }, + { provide: I18nPipe, useValue: mock() }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConnectHuntressDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + mockI18nService.t.mockImplementation((key) => key); + }); + + it("should create the component", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize form with empty values and service name", () => { + expect(component.formGroup.value).toEqual({ + url: "", + token: "", + service: "Huntress", + }); + }); + + it("should have required validators for url and token fields", () => { + component.formGroup.setValue({ url: "", token: "", service: "" }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should require url to be at least 7 characters long", () => { + component.formGroup.setValue({ + url: "test", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeFalsy(); + + component.formGroup.setValue({ + url: "https://hec.huntress.io", + token: "token", + service: "Huntress", + }); + expect(component.formGroup.valid).toBeTruthy(); + }); + + it("should call dialogRef.close with correct result on submit", async () => { + component.formGroup.setValue({ + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).toHaveBeenCalledWith({ + integrationSettings: integrationMock, + url: "https://hec.huntress.io/services/collector", + token: "test-token", + service: "Huntress", + success: IntegrationDialogResultStatus.Edited, + }); + }); + + it("should not submit when form is invalid", async () => { + component.formGroup.setValue({ + url: "", + token: "", + service: "Huntress", + }); + + await component.submit(); + + expect(dialogRefMock.close).not.toHaveBeenCalled(); + expect(component.formGroup.touched).toBeTruthy(); + }); + + it("should return false for isUpdateAvailable when no config exists", () => { + component.huntressConfig = null; + expect(component.isUpdateAvailable).toBeFalsy(); + }); + + it("should return true for isUpdateAvailable when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.isUpdateAvailable).toBeTruthy(); + }); + + it("should return false for canDelete when no config exists", () => { + component.huntressConfig = null; + expect(component.canDelete).toBeFalsy(); + }); + + it("should return true for canDelete when config exists", () => { + component.huntressConfig = { uri: "test", token: "test" } as any; + expect(component.canDelete).toBeTruthy(); + }); +}); + +describe("openHuntressConnectDialog", () => { + it("should call dialogService.open with correct params", () => { + const dialogServiceMock = mock(); + const config: DialogConfig< + HuntressConnectDialogParams, + DialogRef + > = { + data: { settings: { name: "Huntress" } as Integration }, + } as any; + + openHuntressConnectDialog(dialogServiceMock, config); + + expect(dialogServiceMock.open).toHaveBeenCalledWith(ConnectHuntressDialogComponent, config); + }); +}); diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts new file mode 100644 index 00000000000..953a8cdb0ac --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/connect-dialog/connect-dialog-huntress.component.ts @@ -0,0 +1,114 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { HecConfiguration } from "@bitwarden/bit-common/dirt/organization-integrations/models/configuration/hec-configuration"; +import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; +import { DIALOG_DATA, DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { + IntegrationDialogResultStatus, + IntegrationDialogResultStatusType, +} from "../integration-dialog-result-status"; + +export type HuntressConnectDialogParams = { + settings: Integration; +}; + +export interface HuntressConnectDialogResult { + integrationSettings: Integration; + url: string; + token: string; + service: string; + success: IntegrationDialogResultStatusType | null; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "./connect-dialog-huntress.component.html", + imports: [SharedModule], +}) +export class ConnectHuntressDialogComponent implements OnInit { + loading = false; + huntressConfig: HecConfiguration | null = null; + formGroup = this.formBuilder.group({ + url: ["", [Validators.required, Validators.minLength(7)]], + token: ["", Validators.required], + service: [""], // Programmatically set in ngOnInit, not shown to user + }); + + constructor( + @Inject(DIALOG_DATA) protected connectInfo: HuntressConnectDialogParams, + protected formBuilder: FormBuilder, + private dialogRef: DialogRef, + private dialogService: DialogService, + ) {} + + ngOnInit(): void { + this.huntressConfig = + this.connectInfo.settings.organizationIntegration?.getConfiguration() ?? + null; + + this.formGroup.patchValue({ + url: this.huntressConfig?.uri || "", + token: this.huntressConfig?.token || "", + service: this.connectInfo.settings.name, + }); + } + + get isUpdateAvailable(): boolean { + return !!this.huntressConfig; + } + + get canDelete(): boolean { + return !!this.huntressConfig; + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + this.formGroup.markAllAsTouched(); + return; + } + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Edited); + + this.dialogRef.close(result); + + return; + }; + + delete = async (): Promise => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: "deleteItemConfirmation", + }, + type: "warning", + }); + + if (confirmed) { + const result = this.getHuntressConnectDialogResult(IntegrationDialogResultStatus.Delete); + this.dialogRef.close(result); + } + }; + + private getHuntressConnectDialogResult( + status: IntegrationDialogResultStatusType, + ): HuntressConnectDialogResult { + const formJson = this.formGroup.getRawValue(); + + return { + integrationSettings: this.connectInfo.settings, + url: formJson.url || "", + token: formJson.token || "", + service: formJson.service || "", + success: status, + }; + } +} + +export function openHuntressConnectDialog( + dialogService: DialogService, + config: DialogConfig>, +) { + return dialogService.open(ConnectHuntressDialogComponent, config); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts index 9852f3fe5c8..a41ee826cbc 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/index.ts @@ -1,2 +1,4 @@ export * from "./connect-dialog/connect-dialog-hec.component"; export * from "./connect-dialog/connect-dialog-datadog.component"; +export * from "./connect-dialog/connect-dialog-huntress.component"; +export * from "./integration-dialog-result-status"; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts new file mode 100644 index 00000000000..1774088c203 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-dialog/integration-dialog-result-status.ts @@ -0,0 +1,11 @@ +/** + * Shared status types for integration dialog results + * Used across all SIEM integration dialogs (HEC, Datadog, Huntress, etc.) + */ +export const IntegrationDialogResultStatus = { + Edited: "edit", + Delete: "delete", +} as const; + +export type IntegrationDialogResultStatusType = + (typeof IntegrationDialogResultStatus)[keyof typeof IntegrationDialogResultStatus]; diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts index 6517182b21e..5485410f735 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.ts @@ -32,6 +32,7 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { tabIndex: number = 0; organization$: Observable = new Observable(); isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false; + isEventManagementForHuntressEnabled: boolean = false; private destroy$ = new Subject(); // initialize the integrations list with default integrations @@ -258,6 +259,13 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled; }); + this.configService + .getFeatureFlag$(FeatureFlag.EventManagementForHuntress) + .pipe(takeUntil(this.destroy$)) + .subscribe((isEnabled) => { + this.isEventManagementForHuntressEnabled = isEnabled; + }); + // Add the new event based items to the list if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) { const crowdstrikeIntegration: Integration = { @@ -285,6 +293,21 @@ export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy { this.integrationsList.push(datadogIntegration); } + // Add Huntress SIEM integration (separate feature flag) + if (this.isEventManagementForHuntressEnabled) { + const huntressIntegration: Integration = { + name: OrganizationIntegrationServiceName.Huntress, + linkURL: "https://bitwarden.com/help/huntress-siem/", + image: "../../../../../../../images/integrations/logo-huntress-siem.svg", + type: IntegrationType.EVENT, + description: "huntressEventIntegrationDesc", + canSetupConnection: true, + integrationType: OrganizationIntegrationType.Hec, + }; + + this.integrationsList.push(huntressIntegration); + } + // For all existing event based configurations loop through and assign the // organizationIntegration for the correct services. this.organizationIntegrationService.integrations$ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a6b0de1e2e5..c96f6996078 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -56,6 +56,7 @@ export enum FeatureFlag { /* DIRT */ EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", + EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", /* Vault */ @@ -119,6 +120,7 @@ export const DefaultFeatureFlagValue = { /* DIRT */ [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, + [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, /* Vault */ From 3228e986af79c84ba793d162d7df3c66443f6e3e Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Fri, 23 Jan 2026 15:22:32 -0500 Subject: [PATCH 26/52] [PM-30890] Desktop Sync Improvements for Archive (#18466) --- libs/angular/src/vault/components/vault-items.component.ts | 7 +------ .../src/vault/vault-filter/models/vault-filter.model.ts | 6 ++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 563fd48028d..c4fe2741e11 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -194,12 +194,7 @@ export class VaultItemsComponent implements OnDestroy return this.searchService.searchCiphers( userId, searchText, - [ - filter, - this.deletedFilter, - ...(this.deleted ? [] : [this.archivedFilter]), - restrictedTypeFilter, - ], + [filter, restrictedTypeFilter], allCiphers, ); }), diff --git a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts index 83693c85239..d3ad29142e2 100644 --- a/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts +++ b/libs/angular/src/vault/vault-filter/models/vault-filter.model.ts @@ -54,6 +54,12 @@ export class VaultFilter { cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); } + + if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) { + cipherPassesFilter = + !CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher); + } + if (this.cipherType != null && cipherPassesFilter) { cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType; } From 3a70b94b2d418045656f8a14cda5fa69070e5aea Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Fri, 23 Jan 2026 14:30:40 -0800 Subject: [PATCH 27/52] [PM-31199] Fix flaky Vault test (#18544) * Fix flaky spec file * Remove duplicate i18nPipe import that was causing warnings --- .../src/components/carousel/carousel.component.spec.ts | 8 +++----- libs/vault/src/components/carousel/carousel.component.ts | 2 -- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index abbfe963ddf..eb9480398e9 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, ChangeDetectionStrategy } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; @@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-test-carousel-slide", imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + changeDetection: ChangeDetectionStrategy.OnPush, template: ` @@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => { const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; middleSlideButton.nativeElement.click(); - await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. - + fixture.detectChanges(); jest.spyOn(component.slideChange, "emit"); backButton.nativeElement.click(); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index 4e180f09f9b..c622f2e5d85 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -22,7 +22,6 @@ import { take } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ButtonModule, IconButtonModule } from "@bitwarden/components"; -import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, - I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { From a2ea4b784d45518f1a56678293def91723f9814d Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 23 Jan 2026 18:38:23 -0500 Subject: [PATCH 28/52] Force sync to get immediate organization revoke on the extension (#18545) --- .../services/default-vault-items-transfer.service.spec.ts | 8 +++++++- .../src/services/default-vault-items-transfer.service.ts | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts index 51154c3cee9..f5da99cae61 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -16,6 +16,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -43,6 +44,7 @@ describe("DefaultVaultItemsTransferService", () => { let mockEventCollectionService: MockProxy; let mockConfigService: MockProxy; let mockOrganizationUserApiService: MockProxy; + let mockSyncService: MockProxy; const userId = "user-id" as UserId; const organizationId = "org-id" as OrganizationId; @@ -79,6 +81,7 @@ describe("DefaultVaultItemsTransferService", () => { mockEventCollectionService = mock(); mockConfigService = mock(); mockOrganizationUserApiService = mock(); + mockSyncService = mock(); mockI18nService.t.mockImplementation((key) => key); transferInProgressValues = []; @@ -95,6 +98,7 @@ describe("DefaultVaultItemsTransferService", () => { mockEventCollectionService, mockConfigService, mockOrganizationUserApiService, + mockSyncService, ); }); @@ -557,6 +561,8 @@ describe("DefaultVaultItemsTransferService", () => { mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection)); + mockSyncService.fullSync.mockResolvedValue(true); + mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); } it("does nothing when feature flag is disabled", async () => { @@ -635,11 +641,11 @@ describe("DefaultVaultItemsTransferService", () => { mockDialogService.open .mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined)) .mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed)); - mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined); await service.enforceOrganizationDataOwnership(userId); expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); expect(mockToastService.showToast).toHaveBeenCalledWith({ variant: "success", message: "leftOrganization", diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts index 6009fc97e7c..3e65d3157f5 100644 --- a/libs/vault/src/services/default-vault-items-transfer.service.ts +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { getById } from "@bitwarden/common/platform/misc"; import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -54,6 +55,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi private eventCollectionService: EventCollectionService, private configService: ConfigService, private organizationUserApiService: OrganizationUserApiService, + private syncService: SyncService, ) {} private _transferInProgressSubject = new BehaviorSubject(false); @@ -164,7 +166,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi if (!userAcceptedTransfer) { await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id); - this.toastService.showToast({ variant: "success", message: this.i18nService.t("leftOrganization"), @@ -176,6 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi undefined, migrationInfo.enforcingOrganization.id, ); + // Sync to reflect organization removal + await this.syncService.fullSync(true); return; } From 644caceb08951b9e06bed4567c3c66ab554af9bf Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Sun, 25 Jan 2026 12:04:32 -0500 Subject: [PATCH 29/52] Pm 30608 defect the send page is not refreshed after removing the text in the search bar (#18421) * PM-30608 resolved search bug * PM-30608 resolved x button click issue --- 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 a40cb3d4330..d65a8e997fd 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -23,7 +23,7 @@
From 903026b574fa2f8be2565a973a3457cf98454512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 26 Jan 2026 10:53:20 +0100 Subject: [PATCH 30/52] PM-2035: PRF Unlock (web + extension) (#16662) * PM-13632: Enable sign in with passkeys in the browser extension * Refactor component + Icon fix This commit refactors the login-via-webauthn commit as per @JaredSnider-Bitwarden suggestions. It also fixes an existing issue where Icons are not displayed properly on the web vault. Remove old one. Rename the file Working refactor Removed the icon from the component Fixed icons not showing. Changed layout to be 'embedded' * Add tracking links * Update app.module.ts * Remove default Icons on load * Remove login.module.ts * Add env changer to the passkey component * Remove leftover dependencies * PRF Unlock Cleanup and testes * Workaround prf type missing * Fix any type * Undo accidental cleanup to keep PR focused * Undo accidental cleanup to keep PR focused * Cleaned up public interface * Use UserId type * Typed UserId and improved isPrfUnlockAvailable * Rename key and use zero challenge array * logservice * Cleanup rpId handling * Refactor to separate component + icon * Moved the prf unlock service impl. * Fix broken test * fix tests * Use isChromium * Update services.module.ts * missing , in locales * Update desktop-lock-component.service.ts * Fix more desktoptests * Expect a single UnlockOption from IdTokenResponse, but multiple from sync * Missing s * remove catches * Use new control flow in unlock-via-prf.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Changed throw behaviour of unlockVaultWithPrf * remove timeout comment * refactired webauthm-prf-unlock.service internally * WebAuthnPrfUnlockServiceAbstraction -> WebAuthnPrfUnlockService * Fixed any and bad import * Fix errors after merge * Added missing PinServiceAbstraction * Fixed format * Removed @Inject() * Fix broken tests after Inject removal * Return userkey instead of setting it * Used input/output signals * removed duplicate MessageSender registration * nit: Made import relative * Disable onPush requirement because it would need refactoring the component * Added feature flag (#17494) * Fixed ById from main * Import feature flag from file * Add missing test providers for MasterPasswordLockComponent Add WebAuthnPrfUnlockService and DialogService mocks to fix test failures caused by UnlockViaPrfComponent dependencies. --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> --- apps/browser/src/_locales/en/messages.json | 9 + .../extension-lock-component.service.spec.ts | 65 ++-- .../extension-lock-component.service.ts | 31 +- .../src/popup/services/services.module.ts | 39 ++- .../desktop-lock-component.service.spec.ts | 15 + .../desktop-lock-component.service.ts | 3 + apps/web/src/app/core/core.module.ts | 18 ++ .../web-lock-component.service.spec.ts | 11 + .../services/web-lock-component.service.ts | 27 +- apps/web/src/locales/en/messages.json | 9 + .../src/services/jslib-services.module.ts | 2 +- .../webauthn-login.strategy.spec.ts | 2 + .../webauthn-login.strategy.ts | 5 +- .../models/domain/user-decryption-options.ts | 76 +++++ .../user-decryption-options.response.ts | 4 + ...webauthn-prf-decryption-option.response.ts | 19 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../response/user-decryption.response.ts | 13 + .../sync/default-sync.service.spec.ts | 4 +- .../src/platform/sync/default-sync.service.ts | 50 ++- libs/key-management-ui/src/index.ts | 2 + .../src/lock/components/lock.component.html | 8 + .../lock/components/lock.component.spec.ts | 3 + .../src/lock/components/lock.component.ts | 10 + .../master-password-lock.component.html | 5 + .../master-password-lock.component.spec.ts | 7 + .../master-password-lock.component.ts | 7 + .../components/unlock-via-prf.component.ts | 114 +++++++ .../default-webauthn-prf-unlock.service.ts | 288 ++++++++++++++++++ .../lock/services/lock-component.service.ts | 4 + .../services/webauthn-prf-unlock.service.ts | 27 ++ 31 files changed, 810 insertions(+), 69 deletions(-) create mode 100644 libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts create mode 100644 libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts create mode 100644 libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index dabd238e039..61085828cf2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 7678b65d29e..ecdb899b9a7 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -14,7 +14,7 @@ import { BiometricsStatus, BiometricStateService, } from "@bitwarden/key-management"; -import { UnlockOptions } from "@bitwarden/key-management-ui"; +import { UnlockOptions, WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; @@ -34,6 +34,7 @@ describe("ExtensionLockComponentService", () => { let vaultTimeoutSettingsService: MockProxy; let routerService: MockProxy; let biometricStateService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); @@ -43,37 +44,21 @@ describe("ExtensionLockComponentService", () => { vaultTimeoutSettingsService = mock(); routerService = mock(); biometricStateService = mock(); + webAuthnPrfUnlockService = mock(); TestBed.configureTestingModule({ providers: [ - ExtensionLockComponentService, { - provide: UserDecryptionOptionsServiceAbstraction, - useValue: userDecryptionOptionsService, - }, - { - provide: PlatformUtilsService, - useValue: platformUtilsService, - }, - { - provide: BiometricsService, - useValue: biometricsService, - }, - { - provide: PinServiceAbstraction, - useValue: pinService, - }, - { - provide: VaultTimeoutSettingsService, - useValue: vaultTimeoutSettingsService, - }, - { - provide: BrowserRouterService, - useValue: routerService, - }, - { - provide: BiometricStateService, - useValue: biometricStateService, + provide: ExtensionLockComponentService, + useFactory: () => + new ExtensionLockComponentService( + userDecryptionOptionsService, + biometricsService, + pinService, + biometricStateService, + routerService, + webAuthnPrfUnlockService, + ), }, ], }); @@ -212,6 +197,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -234,6 +222,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -256,6 +247,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -278,6 +272,9 @@ describe("ExtensionLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -300,6 +297,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded, }, + prf: { + enabled: false, + }, }, ], [ @@ -322,6 +322,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp, }, + prf: { + enabled: false, + }, }, ], [ @@ -344,6 +347,9 @@ describe("ExtensionLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable, }, + prf: { + enabled: false, + }, }, ], ]; @@ -374,6 +380,9 @@ describe("ExtensionLockComponentService", () => { // PIN pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + // PRF + webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false); + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); expect(unlockOptions).toEqual(expectedOutput); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 9f137d694a9..5e6e564bbc2 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -1,6 +1,3 @@ -// FIXME (PM-22628): angular imports are forbidden in background -// eslint-disable-next-line no-restricted-imports -import { inject } from "@angular/core"; import { combineLatest, defer, firstValueFrom, map, Observable } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; @@ -11,7 +8,11 @@ import { BiometricsStatus, BiometricStateService, } from "@bitwarden/key-management"; -import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + UnlockOptions, + WebAuthnPrfUnlockService, +} from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -21,11 +22,14 @@ import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils"; import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service"; export class ExtensionLockComponentService implements LockComponentService { - private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); - private readonly biometricsService = inject(BiometricsService); - private readonly pinService = inject(PinServiceAbstraction); - private readonly routerService = inject(BrowserRouterService); - private readonly biometricStateService = inject(BiometricStateService); + constructor( + private readonly userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private readonly biometricsService: BiometricsService, + private readonly pinService: PinServiceAbstraction, + private readonly biometricStateService: BiometricStateService, + private readonly routerService: BrowserRouterService, + private readonly webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + ) {} getPreviousUrl(): string | null { return this.routerService.getPreviousUrl() ?? null; @@ -81,8 +85,12 @@ export class ExtensionLockComponentService implements LockComponentService { }), this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), defer(() => this.pinService.isPinDecryptionAvailable(userId)), + defer(async () => { + const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId); + return { available }; + }), ]).pipe( - map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => { + map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable, prfUnlockInfo]) => { const unlockOpts: UnlockOptions = { masterPassword: { enabled: userDecryptionOptions?.hasMasterPassword, @@ -94,6 +102,9 @@ export class ExtensionLockComponentService implements LockComponentService { enabled: biometricsStatus === BiometricsStatus.Available, biometricsStatus: biometricsStatus, }, + prf: { + enabled: prfUnlockInfo.available, + }, }; return unlockOpts; }), diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7b207f0fac1..a8bfb23d83f 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -54,6 +54,7 @@ import { } from "@bitwarden/auto-confirm"; import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service"; import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service"; +import { BrowserRouterService } from "@bitwarden/browser/platform/popup/services/browser-router.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { @@ -71,6 +72,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state"; import { AutofillSettingsService, @@ -96,6 +98,7 @@ import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutService, @@ -160,12 +163,15 @@ import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, + BiometricStateService, DefaultKeyService, KdfConfigService, KeyService, } from "@bitwarden/key-management"; import { LockComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, SessionTimeoutSettingsComponentService, } from "@bitwarden/key-management-ui"; import { DerivedStateProvider, GlobalStateProvider, StateProvider } from "@bitwarden/state"; @@ -572,15 +578,6 @@ const safeProviders: SafeProvider[] = [ useFactory: () => new Subject>>(), deps: [], }), - safeProvider({ - provide: MessageSender, - useFactory: (subject: Subject>>, logService: LogService) => - MessageSender.combine( - new SubjectMessageSender(subject), // For sending messages in the same context - new ChromeMessageSender(logService), // For sending messages to different contexts - ), - deps: [INTRAPROCESS_MESSAGING_SUBJECT, LogService], - }), safeProvider({ provide: DISK_BACKUP_LOCAL_STORAGE, useFactory: (diskStorage: AbstractStorageService & ObservableStorageService) => @@ -604,7 +601,14 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LockComponentService, useClass: ExtensionLockComponentService, - deps: [], + deps: [ + UserDecryptionOptionsServiceAbstraction, + BiometricsService, + PinServiceAbstraction, + BiometricStateService, + BrowserRouterService, + WebAuthnPrfUnlockService, + ], }), // TODO: PM-18182 - Refactor component services into lazy loaded modules safeProvider({ @@ -653,6 +657,21 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, ], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyService, + UserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsService, + WINDOW, + LogService, + ConfigService, + ], + }), safeProvider({ provide: AnimationControlService, useClass: DefaultAnimationControlService, diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts index dd21cf883f3..b01e62d2af3 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts @@ -177,6 +177,9 @@ describe("DesktopLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -197,6 +200,9 @@ describe("DesktopLockComponentService", () => { enabled: true, biometricsStatus: BiometricsStatus.Available, }, + prf: { + enabled: false, + }, }, ], [ @@ -218,6 +224,9 @@ describe("DesktopLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally, }, + prf: { + enabled: false, + }, }, ], [ @@ -238,6 +247,9 @@ describe("DesktopLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable, }, + prf: { + enabled: false, + }, }, ], [ @@ -258,6 +270,9 @@ describe("DesktopLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: false, + }, }, ], ]; diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts index fc57a3873ef..0b1896f02f9 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts @@ -69,6 +69,9 @@ export class DesktopLockComponentService implements LockComponentService { enabled: biometricsStatus == BiometricsStatus.Available, biometricsStatus: biometricsStatus, }, + prf: { + enabled: false, + }, }; return unlockOpts; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 7b248eee8a3..d21b5039d2a 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -65,6 +65,7 @@ import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -127,6 +128,8 @@ import { } from "@bitwarden/key-management"; import { LockComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, SessionTimeoutSettingsComponentService, } from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; @@ -495,6 +498,21 @@ const safeProviders: SafeProvider[] = [ useClass: NoopAuthRequestAnsweringService, deps: [], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsService, + WINDOW, + LogService, + ConfigService, + ], + }), ]; @NgModule({ diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts index 9e993259830..a8e1830971e 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts @@ -5,6 +5,7 @@ import { firstValueFrom, of } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsStatus } from "@bitwarden/key-management"; +import { WebAuthnPrfUnlockService } from "@bitwarden/key-management-ui"; import { WebLockComponentService } from "./web-lock-component.service"; @@ -12,9 +13,11 @@ describe("WebLockComponentService", () => { let service: WebLockComponentService; let userDecryptionOptionsService: MockProxy; + let webAuthnPrfUnlockService: MockProxy; beforeEach(() => { userDecryptionOptionsService = mock(); + webAuthnPrfUnlockService = mock(); TestBed.configureTestingModule({ providers: [ @@ -23,6 +26,10 @@ describe("WebLockComponentService", () => { provide: UserDecryptionOptionsServiceAbstraction, useValue: userDecryptionOptionsService, }, + { + provide: WebAuthnPrfUnlockService, + useValue: webAuthnPrfUnlockService, + }, ], }); @@ -91,6 +98,7 @@ describe("WebLockComponentService", () => { userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce( of(userDecryptionOptions), ); + webAuthnPrfUnlockService.isPrfUnlockAvailable.mockResolvedValue(false); const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); @@ -105,6 +113,9 @@ describe("WebLockComponentService", () => { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: false, + }, }); }); }); diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts index ea038ca2c67..0451aa08689 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts @@ -1,16 +1,18 @@ import { inject } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { combineLatest, defer, map, Observable } from "rxjs"; -import { - UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/common/types/guid"; import { BiometricsStatus } from "@bitwarden/key-management"; -import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; +import { + LockComponentService, + UnlockOptions, + WebAuthnPrfUnlockService, +} from "@bitwarden/key-management-ui"; export class WebLockComponentService implements LockComponentService { private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly webAuthnPrfUnlockService = inject(WebAuthnPrfUnlockService); constructor() {} @@ -43,8 +45,14 @@ export class WebLockComponentService implements LockComponentService { } getAvailableUnlockOptions$(userId: UserId): Observable { - return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId)?.pipe( - map((userDecryptionOptions: UserDecryptionOptions) => { + return combineLatest([ + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(async () => { + const available = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(userId); + return { available }; + }), + ]).pipe( + map(([userDecryptionOptions, prfUnlockInfo]) => { const unlockOpts: UnlockOptions = { masterPassword: { enabled: userDecryptionOptions.hasMasterPassword, @@ -56,6 +64,9 @@ export class WebLockComponentService implements LockComponentService { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported, }, + prf: { + enabled: prfUnlockInfo.available, + }, }; return unlockOpts; }), diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b15d60bf6b5..5a83bc75810 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12101,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index cf41b28baca..7b504548ff5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -886,7 +886,7 @@ const safeProviders: SafeProvider[] = [ FolderApiServiceAbstraction, InternalOrganizationServiceAbstraction, SendApiServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, AvatarServiceAbstraction, LOGOUT_CALLBACK, BillingAccountProfileStateService, diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index 2ae79f46d7c..94d2c6b65aa 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => { WebAuthnPrfOption: { EncryptedPrivateKey: mockEncPrfPrivateKey, EncryptedUserKey: mockEncUserKey, + CredentialId: "mockCredentialId", + Transports: ["usb", "nfc"], }, }; diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index 77a881b5964..019e1d9860e 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy { const userDecryptionOptions = idTokenResponse?.userDecryptionOptions; if (userDecryptionOptions?.webAuthnPrfOption) { - const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption; - const credentials = this.cache.value.credentials; + // confirm we still have the prf key if (!credentials.prfKey) { return; } + const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption; + // decrypt prf encrypted private key const privateKey = await this.encryptService.unwrapDecapsulationKey( webAuthnPrfOption.encryptedPrivateKey, diff --git a/libs/auth/src/common/models/domain/user-decryption-options.ts b/libs/auth/src/common/models/domain/user-decryption-options.ts index 44d8bff4d2c..561a833f3c9 100644 --- a/libs/auth/src/common/models/domain/user-decryption-options.ts +++ b/libs/auth/src/common/models/domain/user-decryption-options.ts @@ -5,6 +5,7 @@ import { Jsonify } from "type-fest"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response"; import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response"; +import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response"; /** * Key Connector decryption options. Intended to be sent to the client for use after authentication. @@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption { } } +/** + * Trusted device decryption options. Intended to be sent to the client for use after authentication. + * @see {@link UserDecryptionOptions} + */ +/** + * WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication. + * @see {@link UserDecryptionOptions} + */ +export class WebAuthnPrfUserDecryptionOption { + /** The encrypted private key that can be decrypted with the PRF key. */ + encryptedPrivateKey: string; + /** The encrypted user key that can be decrypted with the private key. */ + encryptedUserKey: string; + /** The credential ID for this WebAuthn PRF credential. */ + credentialId: string; + /** The transports supported by this credential. */ + transports: string[]; + + /** + * Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object. + * @param response The WebAuthn PRF user decryption option response object. + * @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish. + */ + static fromResponse( + response: WebAuthnPrfDecryptionOptionResponse, + ): WebAuthnPrfUserDecryptionOption | undefined { + if (response == null) { + return undefined; + } + if (!response.encryptedPrivateKey || !response.encryptedUserKey) { + return undefined; + } + const options = new WebAuthnPrfUserDecryptionOption(); + options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString; + options.encryptedUserKey = response.encryptedUserKey.encryptedString; + options.credentialId = response.credentialId; + options.transports = response.transports || []; + return options; + } + + /** + * Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object. + * @param obj JSON object to deserialize. + * @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish. + */ + static fromJSON( + obj: Jsonify, + ): WebAuthnPrfUserDecryptionOption | undefined { + if (obj == null) { + return undefined; + } + return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj); + } +} + /** * Trusted device decryption options. Intended to be sent to the client for use after authentication. * @see {@link UserDecryptionOptions} @@ -104,6 +160,8 @@ export class UserDecryptionOptions { trustedDeviceOption?: TrustedDeviceUserDecryptionOption; /** {@link KeyConnectorUserDecryptionOption} */ keyConnectorOption?: KeyConnectorUserDecryptionOption; + /** Array of {@link WebAuthnPrfUserDecryptionOption} */ + webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[]; /** * Initializes a new instance of the UserDecryptionOptions from a response object. @@ -134,6 +192,18 @@ export class UserDecryptionOptions { decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse( responseOptions.keyConnectorOption, ); + + // The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in + // with the same PRF passkey. + // Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array. + if (responseOptions.webAuthnPrfOption) { + const option = WebAuthnPrfUserDecryptionOption.fromResponse( + responseOptions.webAuthnPrfOption, + ); + if (option) { + decryptionOptions.webAuthnPrfOptions = [option]; + } + } } else { throw new Error( "User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.", @@ -158,6 +228,12 @@ export class UserDecryptionOptions { obj?.keyConnectorOption, ); + if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) { + decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions + .map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option)) + .filter((option) => option !== undefined); + } + return decryptionOptions; } } diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index 4ebadc0daa9..4c5a67d2c31 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse { masterPasswordUnlock?: MasterPasswordUnlockResponse; trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse; keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse; + /** + * The IdTokenresponse only returns a single WebAuthn PRF option. + * To support immediate unlock after logging in with the same PRF passkey. + */ webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse; constructor(response: IUserDecryptionOptionsServerResponse) { diff --git a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts index 478f6d88b5b..b2b5a57ce8f 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts @@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response"; export interface IWebAuthnPrfDecryptionOptionServerResponse { EncryptedPrivateKey: string; EncryptedUserKey: string; + CredentialId: string; + Transports: string[]; } export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse { encryptedPrivateKey: EncString; encryptedUserKey: EncString; + credentialId: string; + transports: string[]; constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) { super(response); - if (response.EncryptedPrivateKey) { - this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey")); + + const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey"); + if (encPrivateKey) { + this.encryptedPrivateKey = new EncString(encPrivateKey); } - if (response.EncryptedUserKey) { - this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey")); + + const encUserKey = this.getResponseProperty("EncryptedUserKey"); + if (encUserKey) { + this.encryptedUserKey = new EncString(encUserKey); } + + this.credentialId = this.getResponseProperty("CredentialId"); + this.transports = this.getResponseProperty("Transports") || []; } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c96f6996078..f761aea1b08 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -42,6 +42,7 @@ export enum FeatureFlag { ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", + PasskeyUnlock = "pm-2035-passkey-unlock", DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit", @@ -153,6 +154,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, + [FeatureFlag.PasskeyUnlock]: FALSE, [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, [FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE, diff --git a/libs/common/src/key-management/models/response/user-decryption.response.ts b/libs/common/src/key-management/models/response/user-decryption.response.ts index b3ac5b80b32..b662834ab01 100644 --- a/libs/common/src/key-management/models/response/user-decryption.response.ts +++ b/libs/common/src/key-management/models/response/user-decryption.response.ts @@ -1,9 +1,15 @@ +import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response"; import { BaseResponse } from "../../../models/response/base.response"; import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response"; export class UserDecryptionResponse extends BaseResponse { masterPasswordUnlock?: MasterPasswordUnlockResponse; + /** + * The sync service returns an array of WebAuthn PRF options. + */ + webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[]; + constructor(response: unknown) { super(response); @@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse { if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") { this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock); } + + const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions"); + if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) { + this.webAuthnPrfOptions = webAuthnPrfOptions.map( + (option) => new WebAuthnPrfDecryptionOptionResponse(option), + ); + } } } diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index fc83954ee7d..bf086ceceaf 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { LogoutReason, UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, + InternalUserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -68,7 +68,7 @@ describe("DefaultSyncService", () => { let folderApiService: MockProxy; let organizationService: MockProxy; let sendApiService: MockProxy; - let userDecryptionOptionsService: MockProxy; + let userDecryptionOptionsService: MockProxy; let avatarService: MockProxy; let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; let billingAccountProfileStateService: MockProxy; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 3c8f6e57e1e..52de14bbc67 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { CollectionService } from "@bitwarden/admin-console/common"; import { - CollectionDetailsResponse, CollectionData, + CollectionDetailsResponse, } from "@bitwarden/common/admin-console/models/collections"; import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service"; import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; @@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security- // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService } from "@bitwarden/key-management"; -// FIXME: remove `src` and fix import +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions"; +import { + InternalUserDecryptionOptionsServiceAbstraction, + UserDecryptionOptions, + WebAuthnPrfUserDecryptionOption, +} from "../../../../auth/src/common"; // FIXME: remove `src` and fix import // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "../../../../auth/src/common/types"; @@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService { folderApiService: FolderApiServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction, sendApiService: SendApiService, - private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private avatarService: AvatarService, private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise, private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService { ); await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf); } + + // Update WebAuthn PRF options if present + if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) { + try { + // Only update if this is the active user, since setUserDecryptionOptions() + // operates on the active user's state + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + if (activeAccount?.id !== userId) { + return; + } + + // Get current options without blocking if they don't exist yet + const currentUserDecryptionOptions = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + ).catch((): UserDecryptionOptions | null => { + return null; + }); + + if (currentUserDecryptionOptions != null) { + // Update the PRF options while preserving other decryption options + const updatedOptions = Object.assign( + new UserDecryptionOptions(), + currentUserDecryptionOptions, + ); + updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions + .map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option)) + .filter((option) => option !== undefined); + + await this.userDecryptionOptionsService.setUserDecryptionOptionsById( + activeAccount.id, + updatedOptions, + ); + } + } catch (error) { + this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error); + } + } } } diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index b273b49cb73..7b9d5a629ac 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -4,6 +4,8 @@ export { LockComponent } from "./lock/components/lock.component"; export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service"; +export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service"; +export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service"; export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component"; export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component"; export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index c1577b76a4d..a93464b265c 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -49,6 +49,8 @@ + + @@ -113,6 +115,11 @@ + + @@ -127,6 +134,7 @@ [unlockOptions]="unlockOptions" [biometricUnlockBtnText]="biometricUnlockBtnText" (successfulUnlock)="successfulMasterPasswordUnlock($event)" + (prfUnlockSuccess)="onPrfUnlockSuccess($event)" (logOut)="logOut()" > } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 054212f8851..47c4d14fc98 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -51,6 +51,7 @@ import { UnlockOptionValue, UnlockOptions, } from "../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; import { LockComponent } from "./lock.component"; @@ -84,6 +85,7 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockWebAuthnPrfUnlockService = mock(); const mockEncryptedMigrator = mock(); const mockActivatedRoute = { snapshot: { @@ -149,6 +151,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, ], diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 03ab6033441..6057fe06456 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -60,6 +60,7 @@ import { } from "../services/lock-component.service"; import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; +import { UnlockViaPrfComponent } from "./unlock-via-prf.component"; const BroadcasterSubscriptionId = "LockComponent"; @@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [ FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, MasterPasswordLockComponent, TooltipDirective, ], @@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy { } } + async onPrfUnlockSuccess(userKey: UserKey): Promise { + await this.setUserKeyAndContinue(userKey); + } + + togglePassword() { + this.showPassword = !this.showPassword; + } + private validatePin(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html index 4c7cdd48353..878915ec6ff 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -54,6 +54,11 @@ } + + diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index dabab3e558a..6d0da1033b7 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, ButtonModule, + DialogService, FormFieldModule, IconButtonModule, ToastService, @@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging"; import { UserId } from "@bitwarden/user-core"; import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; +import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service"; import { MasterPasswordLockComponent } from "./master-password-lock.component"; @@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => { const logService = mock(); const platformUtilsService = mock(); const messageListener = mock(); + const webAuthnPrfUnlockService = mock(); + const dialogService = mock(); const mockMasterPassword = "testExample"; const activeAccount: Account = { @@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally, }, + prf: { enabled: false }, }; accountService.activeAccount$ = of(account); @@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => { { provide: LogService, useValue: logService }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: MessageListener, useValue: messageListener }, + { provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts index 1237869717f..5229effd366 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -36,6 +36,7 @@ import { UnlockOptions, UnlockOptionValue, } from "../../services/lock-component.service"; +import { UnlockViaPrfComponent } from "../unlock-via-prf.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -49,6 +50,7 @@ import { FormFieldModule, AsyncActionsModule, IconButtonModule, + UnlockViaPrfComponent, ], }) export class MasterPasswordLockComponent implements OnInit, OnDestroy { @@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + prfUnlockSuccess = output(); logOut = output(); protected showPassword = false; @@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy { }); } } + + onPrfUnlockSuccess(userKey: UserKey): void { + this.prfUnlockSuccess.emit(userKey); + } } diff --git a/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts new file mode 100644 index 00000000000..7a0b99b232d --- /dev/null +++ b/libs/key-management-ui/src/lock/components/unlock-via-prf.component.ts @@ -0,0 +1,114 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit, input, output } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components"; + +import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service"; + +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-unlock-via-prf", + standalone: true, + imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule], + template: ` + @if (isAvailable) { + @if (formButton()) { + + } + @if (!formButton()) { + + } + } + `, +}) +export class UnlockViaPrfComponent implements OnInit { + readonly formButton = input(false); + readonly unlockSuccess = output(); + + unlocking = false; + isAvailable = false; + private userId: UserId | null = null; + + constructor( + private accountService: AccountService, + private webAuthnPrfUnlockService: WebAuthnPrfUnlockService, + private dialogService: DialogService, + private i18nService: I18nService, + private logService: LogService, + ) {} + + async ngOnInit(): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (activeAccount?.id) { + this.userId = activeAccount.id; + this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId); + } + } + + async unlockViaPrf(): Promise { + if (!this.userId || !this.isAvailable) { + return; + } + + this.unlocking = true; + + try { + const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId); + this.unlockSuccess.emit(userKey); + } catch (error) { + this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error); + + let errorMessage = this.i18nService.t("unexpectedError"); + + // Handle specific PRF error cases + if (error instanceof Error) { + if (error.message.includes("No PRF credentials")) { + errorMessage = this.i18nService.t("noPrfCredentialsAvailable"); + } else if (error.message.includes("canceled")) { + // User canceled the operation, don't show error + this.unlocking = false; + return; + } + } + + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: errorMessage, + acceptButtonText: { key: "ok" }, + type: "danger", + }); + } finally { + this.unlocking = false; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..960a663b589 --- /dev/null +++ b/libs/key-management-ui/src/lock/services/default-webauthn-prf-unlock.service.ts @@ -0,0 +1,288 @@ +import { firstValueFrom } from "rxjs"; + +import { + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, + WebAuthnPrfUserDecryptionOption, +} from "@bitwarden/auth/common"; +import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service"; + +export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService { + private navigatorCredentials: CredentialsContainer; + + constructor( + private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction, + private keyService: KeyService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private encryptService: EncryptService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private window: Window, + private logService: LogService, + private configService: ConfigService, + ) { + this.navigatorCredentials = this.window.navigator.credentials; + } + + async isPrfUnlockAvailable(userId: UserId): Promise { + try { + // Check if feature flag is enabled + const passkeyUnlockEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PasskeyUnlock, + ); + if (!passkeyUnlockEnabled) { + return false; + } + + // Check if browser supports WebAuthn + if (!this.navigatorCredentials || !this.navigatorCredentials.get) { + return false; + } + + // If we're in the browser extension, check if we're in a Chromium browser + if ( + this.platformUtilsService.getClientType() === ClientType.Browser && + !this.platformUtilsService.isChromium() + ) { + return false; + } + + // Check if user has any WebAuthn PRF credentials registered + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + return false; + } + + return true; + } catch (error) { + this.logService.error("Error checking PRF unlock availability:", error); + return false; + } + } + + private async getPrfUnlockCredentials( + userId: UserId, + ): Promise<{ credentialId: string; transports: string[] }[]> { + try { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + if (!userDecryptionOptions?.webAuthnPrfOptions) { + return []; + } + return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({ + credentialId: option.credentialId, + transports: option.transports, + })); + } catch (error) { + this.logService.error("Error getting PRF unlock credentials:", error); + return []; + } + } + + /** + * Unlocks the vault using WebAuthn PRF. + * + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if unlock fails for any reason + */ + async unlockVaultWithPrf(userId: UserId): Promise { + // Get offline PRF credentials from user decryption options + const credentials = await this.getPrfUnlockCredentials(userId); + if (credentials.length === 0) { + throw new Error("No PRF credentials available for unlock"); + } + + const response = await this.performWebAuthnGetWithPrf(credentials, userId); + const prfKey = await this.createPrfKeyFromResponse(response); + const prfOption = await this.getPrfOptionForCredential(response.id, userId); + + // PRF unlock follows the same key derivation process as PRF login: + // PRF key → decrypt private key → use private key to decrypt user key + + // Step 1: Decrypt PRF encrypted private key using the PRF key + const privateKey = await this.encryptService.unwrapDecapsulationKey( + new EncString(prfOption.encryptedPrivateKey), + prfKey, + ); + + // Step 2: Use private key to decrypt user key + const userKey = await this.encryptService.decapsulateKeyUnsigned( + new EncString(prfOption.encryptedUserKey), + privateKey, + ); + + if (!userKey) { + throw new Error("Failed to decrypt user key from private key"); + } + + return userKey as UserKey; + } + + /** + * Performs WebAuthn get operation with PRF extension. + * + * @param credentials Available PRF credentials for the user + * @returns PublicKeyCredential response from the authenticator + * @throws Error if WebAuthn operation fails or returns invalid response + */ + private async performWebAuthnGetWithPrf( + credentials: { credentialId: string; transports: string[] }[], + userId: UserId, + ): Promise { + const rpId = await this.getRpIdForUser(userId); + const prfSalt = await this.getUnlockWithPrfSalt(); + + const options: CredentialRequestOptions = { + publicKey: { + challenge: new Uint8Array(32), + allowCredentials: credentials.map(({ credentialId, transports }) => { + // The credential ID is already base64url encoded from login storage + // We need to decode it to ArrayBuffer for WebAuthn + const decodedId = Fido2Utils.stringToBuffer(credentialId); + return { + type: "public-key", + id: decodedId, + transports: (transports || []) as AuthenticatorTransport[], + }; + }), + rpId, + userVerification: "preferred", // Allow platform authenticators to work properly + extensions: { + prf: { eval: { first: prfSalt } }, + } as any, + }, + }; + + const response = await this.navigatorCredentials.get(options); + + if (!response) { + throw new Error("WebAuthn get() returned null/undefined"); + } + + if (!(response instanceof PublicKeyCredential)) { + throw new Error("Failed to get PRF credential for unlock"); + } + + return response; + } + + /** + * Extracts PRF result from WebAuthn response and creates a PrfKey. + * + * @param response PublicKeyCredential response from authenticator + * @returns PrfKey derived from the PRF extension output + * @throws Error if no PRF result is present in the response + */ + private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise { + // Extract PRF result + // TODO: Remove `any` when typescript typings add support for PRF + const extensionResults = response.getClientExtensionResults() as any; + const prfResult = extensionResults.prf?.results?.first; + if (!prfResult) { + throw new Error("No PRF result received from authenticator"); + } + + try { + return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult); + } catch (error) { + this.logService.error("Failed to create unlock key from PRF:", error); + throw error; + } + } + + /** + * Gets the WebAuthn PRF option that matches the credential used in the response. + * + * @param credentialId Credential ID to match + * @param userId User ID to get decryption options for + * @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys + * @throws Error if no PRF options exist or no matching option is found + */ + private async getPrfOptionForCredential( + credentialId: string, + userId: UserId, + ): Promise { + const userDecryptionOptions = await this.getUserDecryptionOptions(userId); + + if ( + !userDecryptionOptions?.webAuthnPrfOptions || + userDecryptionOptions.webAuthnPrfOptions.length === 0 + ) { + throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock"); + } + + const prfOption = userDecryptionOptions.webAuthnPrfOptions.find( + (option) => option.credentialId === credentialId, + ); + + if (!prfOption) { + throw new Error("No matching WebAuthn PRF option found for this credential"); + } + + return prfOption; + } + + private async getUnlockWithPrfSalt(): Promise { + try { + // Use the same salt as login to ensure PRF keys match + return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt(); + } catch (error) { + this.logService.error("Error getting unlock PRF salt:", error); + throw error; + } + } + + /** + * Helper method to get user decryption options for a user + */ + private async getUserDecryptionOptions(userId: UserId): Promise { + try { + return (await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + )) as UserDecryptionOptions; + } catch (error) { + this.logService.error("Error getting user decryption options:", error); + return null; + } + } + + /** + * Helper method to get the appropriate rpId for WebAuthn PRF operations + * Returns the hostname from the user's environment configuration + */ + private async getRpIdForUser(userId: UserId): Promise { + try { + const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const hostname = environment.getHostname(); + + // The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host. + if (!hostname) { + return undefined; + } + + // Extract hostname using URL parsing to handle IPv6 and ports correctly + // This removes ports etc. + const url = new URL(`https://${hostname}`); + const rpId = url.hostname; + + return rpId; + } catch (error) { + this.logService.error("Error getting rpId", error); + return undefined; + } + } +} diff --git a/libs/key-management-ui/src/lock/services/lock-component.service.ts b/libs/key-management-ui/src/lock/services/lock-component.service.ts index 0fc25ca7dfb..53cb256f251 100644 --- a/libs/key-management-ui/src/lock/services/lock-component.service.ts +++ b/libs/key-management-ui/src/lock/services/lock-component.service.ts @@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({ MasterPassword: "masterPassword", Pin: "pin", Biometrics: "biometrics", + Prf: "prf", }) satisfies { [Prop in keyof UnlockOptions as Capitalize]: Prop }; export type UnlockOptions = { @@ -23,6 +24,9 @@ export type UnlockOptions = { enabled: boolean; biometricsStatus: BiometricsStatus; }; + prf: { + enabled: boolean; + }; }; /** diff --git a/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts new file mode 100644 index 00000000000..f0b02a0ed3f --- /dev/null +++ b/libs/key-management-ui/src/lock/services/webauthn-prf-unlock.service.ts @@ -0,0 +1,27 @@ +import { UserKey } from "@bitwarden/common/types/key"; +import { UserId } from "@bitwarden/user-core"; + +/** + * Service for unlocking vault using WebAuthn PRF. + * Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs. + */ +export abstract class WebAuthnPrfUnlockService { + /** + * Check if PRF unlock is available for the current user + * @param userId The user ID to check PRF unlock availability for + * @returns Promise true if PRF unlock is available + */ + abstract isPrfUnlockAvailable(userId: UserId): Promise; + + /** + * Attempt to unlock the vault using WebAuthn PRF + * @param userId The user ID to unlock vault for + * @returns Promise the decrypted user key + * @throws Error if no PRF credentials are available + * @throws Error if the authenticator returns no PRF result + * @throws Error if the user cancels the WebAuthn operation + * @throws Error if decryption of the user key fails + * @throws Error if no matching PRF option is found for the credential + */ + abstract unlockVaultWithPrf(userId: UserId): Promise; +} From 71db33d45d07dad08309dd348e6887d9dad4af75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:38:10 +0000 Subject: [PATCH 31/52] [PM-28842] Add max length validation to master password policy form (#18237) * Update master password policy dialog to limit the minimum length to 128 * Update master password policy to use dynamic maximum length from Utils * Add unit tests for MasterPasswordPolicyComponent to validate password length constraints and scoring --- .../master-password.component.html | 1 + .../master-password.component.spec.ts | 69 +++++++++++++++++++ .../master-password.component.ts | 6 +- libs/common/src/platform/misc/utils.ts | 1 + 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html index 63a59208cc0..f979c143a3a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.html @@ -32,6 +32,7 @@ formControlName="minLength" id="minLength" [min]="MinPasswordLength" + [max]="MaxPasswordLength" />
diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts new file mode 100644 index 00000000000..b22f5687dd2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.spec.ts @@ -0,0 +1,69 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; + +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 { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { MasterPasswordPolicyComponent } from "./master-password.component"; + +describe("MasterPasswordPolicyComponent", () => { + let component: MasterPasswordPolicyComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: mock() }, + { provide: OrganizationService, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordPolicyComponent); + component = fixture.componentInstance; + }); + + it("should accept minimum password length of 12", () => { + component.data.patchValue({ minLength: 12 }); + + expect(component.data.get("minLength")?.valid).toBe(true); + }); + + it("should accept maximum password length of 128", () => { + component.data.patchValue({ minLength: 128 }); + + expect(component.data.get("minLength")?.valid).toBe(true); + }); + + it("should reject password length below minimum", () => { + component.data.patchValue({ minLength: 11 }); + + expect(component.data.get("minLength")?.hasError("min")).toBe(true); + }); + + it("should reject password length above maximum", () => { + component.data.patchValue({ minLength: 129 }); + + expect(component.data.get("minLength")?.hasError("max")).toBe(true); + }); + + it("should use correct minimum from Utils", () => { + expect(component.MinPasswordLength).toBe(Utils.minimumPasswordLength); + expect(component.MinPasswordLength).toBe(12); + }); + + it("should use correct maximum from Utils", () => { + expect(component.MaxPasswordLength).toBe(Utils.maximumPasswordLength); + expect(component.MaxPasswordLength).toBe(128); + }); + + it("should have password scores from 0 to 4", () => { + const scores = component.passwordScores.filter((s) => s.value !== null).map((s) => s.value); + + expect(scores).toEqual([0, 1, 2, 3, 4]); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts index e9926b2aeb1..dd2463d718d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/master-password.component.ts @@ -34,10 +34,14 @@ export class MasterPasswordPolicy extends BasePolicyEditDefinition { }) export class MasterPasswordPolicyComponent extends BasePolicyEditComponent implements OnInit { MinPasswordLength = Utils.minimumPasswordLength; + MaxPasswordLength = Utils.maximumPasswordLength; data: FormGroup> = this.formBuilder.group({ minComplexity: [null], - minLength: [this.MinPasswordLength, [Validators.min(Utils.minimumPasswordLength)]], + minLength: [ + this.MinPasswordLength, + [Validators.min(Utils.minimumPasswordLength), Validators.max(this.MaxPasswordLength)], + ], requireUpper: [false], requireLower: [false], requireNumbers: [false], diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 136b0ac394f..bdbfc4ea17b 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -42,6 +42,7 @@ export class Utils { static readonly validHosts: string[] = ["localhost"]; static readonly originalMinimumPasswordLength = 8; static readonly minimumPasswordLength = 12; + static readonly maximumPasswordLength = 128; static readonly DomainMatchBlacklist = new Map>([ ["google.com", new Set(["script.google.com"])], ]); From b744164f7a5401e23981a499e8b18e1504b07958 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:33:55 +0000 Subject: [PATCH 32/52] Autosync the updated translations (#18559) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/ar/messages.json | 9 +++ apps/browser/src/_locales/az/messages.json | 9 +++ apps/browser/src/_locales/be/messages.json | 9 +++ apps/browser/src/_locales/bg/messages.json | 9 +++ apps/browser/src/_locales/bn/messages.json | 9 +++ apps/browser/src/_locales/bs/messages.json | 9 +++ apps/browser/src/_locales/ca/messages.json | 9 +++ apps/browser/src/_locales/cs/messages.json | 9 +++ apps/browser/src/_locales/cy/messages.json | 9 +++ apps/browser/src/_locales/da/messages.json | 9 +++ apps/browser/src/_locales/de/messages.json | 9 +++ apps/browser/src/_locales/el/messages.json | 9 +++ apps/browser/src/_locales/en_GB/messages.json | 9 +++ apps/browser/src/_locales/en_IN/messages.json | 9 +++ apps/browser/src/_locales/es/messages.json | 9 +++ apps/browser/src/_locales/et/messages.json | 9 +++ apps/browser/src/_locales/eu/messages.json | 9 +++ apps/browser/src/_locales/fa/messages.json | 9 +++ apps/browser/src/_locales/fi/messages.json | 9 +++ apps/browser/src/_locales/fil/messages.json | 9 +++ apps/browser/src/_locales/fr/messages.json | 9 +++ apps/browser/src/_locales/gl/messages.json | 9 +++ apps/browser/src/_locales/he/messages.json | 67 +++++++++++-------- apps/browser/src/_locales/hi/messages.json | 9 +++ apps/browser/src/_locales/hr/messages.json | 9 +++ apps/browser/src/_locales/hu/messages.json | 9 +++ apps/browser/src/_locales/id/messages.json | 9 +++ apps/browser/src/_locales/it/messages.json | 9 +++ apps/browser/src/_locales/ja/messages.json | 9 +++ apps/browser/src/_locales/ka/messages.json | 9 +++ apps/browser/src/_locales/km/messages.json | 9 +++ apps/browser/src/_locales/kn/messages.json | 9 +++ apps/browser/src/_locales/ko/messages.json | 9 +++ apps/browser/src/_locales/lt/messages.json | 9 +++ apps/browser/src/_locales/lv/messages.json | 9 +++ apps/browser/src/_locales/ml/messages.json | 9 +++ apps/browser/src/_locales/mr/messages.json | 9 +++ apps/browser/src/_locales/my/messages.json | 9 +++ apps/browser/src/_locales/nb/messages.json | 9 +++ apps/browser/src/_locales/ne/messages.json | 9 +++ apps/browser/src/_locales/nl/messages.json | 9 +++ apps/browser/src/_locales/nn/messages.json | 9 +++ apps/browser/src/_locales/or/messages.json | 9 +++ apps/browser/src/_locales/pl/messages.json | 9 +++ apps/browser/src/_locales/pt_BR/messages.json | 9 +++ apps/browser/src/_locales/pt_PT/messages.json | 11 ++- apps/browser/src/_locales/ro/messages.json | 9 +++ apps/browser/src/_locales/ru/messages.json | 9 +++ apps/browser/src/_locales/si/messages.json | 9 +++ apps/browser/src/_locales/sk/messages.json | 9 +++ apps/browser/src/_locales/sl/messages.json | 9 +++ apps/browser/src/_locales/sr/messages.json | 9 +++ apps/browser/src/_locales/sv/messages.json | 9 +++ apps/browser/src/_locales/ta/messages.json | 9 +++ apps/browser/src/_locales/te/messages.json | 9 +++ apps/browser/src/_locales/th/messages.json | 9 +++ apps/browser/src/_locales/tr/messages.json | 9 +++ apps/browser/src/_locales/uk/messages.json | 21 ++++-- apps/browser/src/_locales/vi/messages.json | 9 +++ apps/browser/src/_locales/zh_CN/messages.json | 15 ++++- apps/browser/src/_locales/zh_TW/messages.json | 9 +++ 61 files changed, 588 insertions(+), 39 deletions(-) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index e787876c53d..937672bfd60 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "تسجيل الدخول باستخدام مفتاح المرور" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "استخدام تسجيل الدخول الأحادي" }, @@ -3367,6 +3370,12 @@ "error": { "message": "خطأ" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "خطأ فك التشفير" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 1f87a6046f4..58c9b5a0cb8 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Keçid açarı ilə giriş et" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Xəta" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Şifrə açma xətası" }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 04e6e4cab52..68277cfeb00 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Увайсці з ключом доступу" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Выкарыстаць аднаразовы ўваход" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Памылка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 4c7bed288be..05ee1fc5765 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Вписване със секретен ключ" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Грешка при дешифриране" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 306e5ac1c29..fa4d93fa9ee 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index dd153c64330..7eb327b034a 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index ccfb35ce021..3a9333b5471 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inicieu sessió amb la clau de pas" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Inici de sessió únic" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Error de desxifrat" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 8ccc9a38221..46618df6257 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Přihlásit se pomocí přístupového klíče" }, + "unlockWithPasskey": { + "message": "Odemknout pomocí přístupového klíče" + }, "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Chyba" }, + "prfUnlockFailed": { + "message": "Nepodařilo se odemknout pomocí přístupového klíče. Zkuste to znovu nebo použijte jinou metodu odemknutí." + }, + "noPrfCredentialsAvailable": { + "message": "K odemknutí nejsou k dispozici žádné přístupové klíče s podporou PRF. Nejprve se přihlaste pomocí hesla." + }, "decryptionError": { "message": "Chyba dešifrování" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 627ceda87ba..d765b7d8a10 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Gwall" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 7990cec986d..5add4d4b10c 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log ind med adgangsnøgle" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Brug Single Sign-On" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Fejl" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfejl" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 40d0156c932..8579ebdee3e 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Mit Passkey anmelden" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Single Sign-On verwenden" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Fehler" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Entschlüsselungsfehler" }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 9838ef32bbc..d1eebc0362c 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Σύνδεση με κλειδί πρόσβασης" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Χρήση ενιαίας σύνδεσης" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Σφάλμα" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Σφάλμα αποκρυπτογράφησης" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 2da5ac9e3dd..68cf36cacde 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 04b62e9f880..216db1911f2 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index ec7030abfdd..6eca24db96e 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sesión con clave de acceso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Error de descifrado" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index ab13fc6848d..72f9c553569 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logi sisse pääsuvõtmega" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Viga" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index b2b27b5fbac..04e673d2230 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Akatsa" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 8663553b3bb..a3ea290de39 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "با کلید عبور وارد شوید" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, @@ -3367,6 +3370,12 @@ "error": { "message": "خطا" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "خطای رمزگشایی" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 5c6da9a87fb..0e19e256714 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Kirjaudu pääsyavaimella" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Virhe" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Salauksen purkuvirhe" }, diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 52424a32d47..b44f5210ccd 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Mali" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 81c48805014..6b5348f564f 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Se connecter avec une clé d'accès" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Erreur" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erreur de déchiffrement" }, diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 2851878948e..faf9faf755d 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sesión con Clave de acceso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar inicio de sesión único" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erro de descifrado" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 7ffe250a1d4..3d953f508a1 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "כניסה עם מפתח גישה" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, @@ -437,7 +440,7 @@ "message": "סנכרן" }, "syncNow": { - "message": "Sync now" + "message": "סנכרון כעת" }, "lastSync": { "message": "סנכרון אחרון:" @@ -574,7 +577,7 @@ "message": "הפריט נשלח לארכיון" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "הפריט שוחזר מהארכיב" }, "itemUnarchived": { "message": "הפריט הוסר מהארכיון" @@ -583,19 +586,19 @@ "message": "העבר פריט לארכיון" }, "archiveItemDialogContent": { - "message": "Once archived, this item will be excluded from search results and autofill suggestions." + "message": "עם ארכובו, יהיה הפריט מוחרג מתוצאות החיפוש ומהצעות המילוי האוטומטי." }, "archived": { - "message": "Archived" + "message": "הועבר לארכיב" }, "unarchiveAndSave": { - "message": "Unarchive and save" + "message": "שחזור מהארכיב ושמירה" }, "upgradeToUseArchive": { - "message": "A premium membership is required to use Archive." + "message": "נדרשת חברות פרמיום כדי להשתמש בארכיב." }, "itemRestored": { - "message": "Item has been restored" + "message": "הפריט שוחזר" }, "edit": { "message": "ערוך" @@ -607,7 +610,7 @@ "message": "הצג הכל" }, "showAll": { - "message": "Show all" + "message": "הצגת הכל" }, "viewLess": { "message": "הצג פחות" @@ -1335,19 +1338,19 @@ "message": "ייצא מ־" }, "exportVerb": { - "message": "Export", + "message": "ייצוא", "description": "The verb form of the word Export" }, "exportNoun": { - "message": "Export", + "message": "ייצוא", "description": "The noun form of the word Export" }, "importNoun": { - "message": "Import", + "message": "ייבוא", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "ייבוא", "description": "The verb form of the word Import" }, "fileFormat": { @@ -1429,25 +1432,25 @@ "message": "למידע נוסף" }, "migrationsFailed": { - "message": "An error occurred updating the encryption settings." + "message": "אירעה שגיאה בעת עדכון הגדרות ההצפנה." }, "updateEncryptionSettingsTitle": { - "message": "Update your encryption settings" + "message": "עדכון הגדרות ההצפנה שלך" }, "updateEncryptionSettingsDesc": { - "message": "The new recommended encryption settings will improve your account security. Enter your master password to update now." + "message": "הגדרות ההצפנה המומלצות החדשות ישפרו את אבטחת החשבון שלך. יש להזין את הסיסמה הראשית שלך כדי לעדכן כעת." }, "confirmIdentityToContinue": { - "message": "Confirm your identity to continue" + "message": "יש לאשר את זהותך כדי להמשיך" }, "enterYourMasterPassword": { - "message": "Enter your master password" + "message": "נא להזין את הסיסמה הראשית שלך" }, "updateSettings": { - "message": "Update settings" + "message": "עדכון ההגדרות" }, "later": { - "message": "Later" + "message": "מאוחר יותר" }, "authenticatorKeyTotp": { "message": "מפתח מאמת (TOTP)" @@ -1480,13 +1483,13 @@ "message": "הצרופה נשמרה" }, "fixEncryption": { - "message": "Fix encryption" + "message": "תיקון ההצפנה" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "הקובץ מוגדר בשיטת הצפנה לא עדכנית." }, "attachmentUpdated": { - "message": "Attachment updated" + "message": "הצרופה עודכנה" }, "file": { "message": "קובץ" @@ -1498,7 +1501,7 @@ "message": "בחר קובץ" }, "itemsTransferred": { - "message": "Items transferred" + "message": "הפריטים הועברו" }, "maxFileSize": { "message": "גודל הקובץ המרבי הוא 500MB." @@ -1531,7 +1534,7 @@ "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, "premiumSignUpStorageV2": { - "message": "$SIZE$ encrypted storage for file attachments.", + "message": "$SIZE$ של אחסון מוצפן עבור קבצים מצורפים.", "placeholders": { "size": { "content": "$1", @@ -1546,13 +1549,13 @@ "message": "אפשרויות כניסה דו־שלבית קנייניות כגון YubiKey ו־Duo." }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "מנוי הפרמיום שלך הסתיים" }, "archivePremiumRestart": { - "message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault." + "message": "לשחזור הגישה לארכיב שלך יש לחדש את מנוי הפרמיום שלך. אם תבצעו עריכת פרטים של פריט בארכיב לפני חידוש המנוי, הפריט ישוחזר אל הכספת שלכם." }, "restartPremium": { - "message": "Restart Premium" + "message": "חידוש מנוי הפרמיום" }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." @@ -1947,7 +1950,7 @@ "message": "שנת תפוגה" }, "monthly": { - "message": "month" + "message": "חודש" }, "expiration": { "message": "תוקף" @@ -2474,7 +2477,7 @@ "message": "הפריט נמחק לצמיתות" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "פריט שוחזר מהארכיב" }, "restoreItem": { "message": "שחזר פריט" @@ -3367,6 +3370,12 @@ "error": { "message": "שגיאה" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "שגיאת פענוח" }, diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 04f966db4d3..ea0eb362a0d 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "सिंगल साइन-ऑन प्रयोग करें" }, @@ -3367,6 +3370,12 @@ "error": { "message": "एरर" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 4ff9d75b012..b7dbed3dcc0 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prijava pristupnim ključem" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Pogreška" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Pogreška pri dešifriranju" }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index c0da3813fae..fb9e327337c 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Bejelentkezés hozzáférési kulccsal" }, + "unlockWithPasskey": { + "message": "Hozzáférési kulcs" + }, "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Hiba" }, + "prfUnlockFailed": { + "message": "Nem sikerült a feloldás a hozzéférési kulccsal. Próbáljuk újra vagy használjunk más feloldási metódust." + }, + "noPrfCredentialsAvailable": { + "message": "A feloldáshoz nem állnak rendelkezésre PRF kompatibilis hozzáférési kucsok. Először jelentkezzünk be egy hozzáférési kulccsal." + }, "decryptionError": { "message": "Visszafejtési hiba" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 75e144ea800..064e67eb76f 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Masuk dengan kunci sandi" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Gunakan masuk tunggal" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Galat" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Kesalahan dekripsi" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index b255b738541..3e47b38f141 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Accedi con passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usa il Single Sign-On" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Errore" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Errore di decifrazione" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 049ca5599d4..9784ad44f2a 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "パスキーでログイン" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "シングルサインオンを使用する" }, @@ -3367,6 +3370,12 @@ "error": { "message": "エラー" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "復号エラー" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 1c25b51696e..d74b4f225fe 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "შეცდომა" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index a9fd0f8f2be..c15ab367666 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 512ee18fa52..20e1cec5280 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index ae7b92faab6..8cedaf14acc 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "패스키를 사용하여 로그인하기" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "통합인증(SSO) 사용하기" }, @@ -3367,6 +3370,12 @@ "error": { "message": "오류" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 7e1b9ddb49a..eac510ea668 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prisijungti naudojant prieigos raktą" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Naudoti vieningo prisijungimo sistemą" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Klaida" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index cc887c6e9a3..6c5ee5adb98 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Pieteikties ar piekļuves atslēgu" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Kļūda" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Atšifrēšanas kļūda" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 8676ae8dcd7..35ff7b94d4c 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index ec5e5b84f9a..bae23dcd94d 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index a9fd0f8f2be..c15ab367666 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index d3164c3cba0..993d7a1f0db 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logg inn med passnøkkel" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Bruk singulær pålogging" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Feil" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfeil" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index a9fd0f8f2be..c15ab367666 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 5dd1dbdf059..504868fc5c8 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Inloggen met passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Single sign-on gebruiken" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Fout" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Ontsleutelingsfout" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index a9fd0f8f2be..c15ab367666 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index a9fd0f8f2be..c15ab367666 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 4163f420db1..f8d8d6bfd69 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logowanie kluczem dostępu" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Błąd" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Błąd odszyfrowywania" }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 2fbd7dfccbd..a83d15be1b1 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Conectar-se com chave de acesso" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Usar autenticação única" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Erro de descriptografia" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 4c2e115ddd5..2b40e2003a5 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Iniciar sessão com a chave de acesso" }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, "useSingleSignOn": { "message": "Utilizar início de sessão único" }, @@ -1552,7 +1555,7 @@ "message": "Para recuperar o acesso ao seu arquivo, reinicie a sua subscrição Premium. Se editar os detalhes de um item arquivado antes de reiniciar, ele será movido de volta para o seu cofre." }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "ppremiumSignUpReports": { "message": "Higiene de palavras-passe, saúde da conta e relatórios de violação de dados para manter o seu cofre seguro." @@ -3367,6 +3370,12 @@ "error": { "message": "Erro" }, + "prfUnlockFailed": { + "message": "Não foi possível desbloquear com a chave de acesso. Por favor, tente novamente ou utilize outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Não estão disponíveis chaves de acesso com PRF ativado para o desbloqueio. Por favor, inicie sessão primeiro com uma chave de acesso." + }, "decryptionError": { "message": "Erro de desencriptação" }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 3f80db9688a..b071d8c765e 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Autentificare cu parolă" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Autentificare unică" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Eroare" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 2d08ed120df..c2b09803c06 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Войти с passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Использовать единый вход" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Ошибка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Ошибка расшифровки" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index b242feae38a..c2451a18133 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 1b995911d87..5e1511eebac 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Prihlásiť sa s prístupovým kľúčom" }, + "unlockWithPasskey": { + "message": "Odomknúť pomocou prístupového kľúča" + }, "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Chyba" }, + "prfUnlockFailed": { + "message": "Odomknutie pomocou prístupového kľúča zlyhalo. Skúste to znovu alebo použite inú metódu odomknutia." + }, + "noPrfCredentialsAvailable": { + "message": "Na odomkntuie nie sú k dispozícii žiadne PRF-enabled prístupové kľúče. Najskôr sa prihláste pomocou prístupového kľúča." + }, "decryptionError": { "message": "Chyba dešifrovania" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 214b1949b9d..23d0312caae 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Napaka" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index eac015f3cbf..d3b5e961ef3 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Пријавите се са приступним кључем" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Грешка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Грешка при декрипцији" }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index d5eb1c5149b..ca5984b672e 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Logga in med nyckel" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Använd Single Sign-On" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Fel" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Dekrypteringsfel" }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index dea81448f5e..44a284db9c6 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "பாஸ்கீயுடன் உள்நுழையவும்" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, @@ -3367,6 +3370,12 @@ "error": { "message": "பிழை" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "குறியாக்கம் நீக்கப் பிழை" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index a9fd0f8f2be..c15ab367666 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Log in with passkey" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Use single sign-on" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Error" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Decryption error" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 51add21b5a2..d41ae49904d 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "เข้าสู่ระบบด้วยพาสคีย์" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "ใช้การลงชื่อเข้าใช้แบบ SSO" }, @@ -3367,6 +3370,12 @@ "error": { "message": "ข้อผิดพลาด" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "ข้อผิดพลาดในการถอดรหัส" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 25f9e8ad706..83461d1a8a0 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Geçiş anahtarıyla giriş yap" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Hata" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Şifre çözme sorunu" }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 02c6d0ca3a6..fdbd2508c44 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Увійти з ключем доступу" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Використати єдиний вхід" }, @@ -583,7 +586,7 @@ "message": "Архівувати запис" }, "archiveItemDialogContent": { - "message": "Once archived, this item will be excluded from search results and autofill suggestions." + "message": "Після архівації цей запис буде виключено з результатів пошуку і пропозицій автозаповнення." }, "archived": { "message": "Архівовано" @@ -2474,7 +2477,7 @@ "message": "Запис остаточно видалено" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Архівований запис відновлено" }, "restoreItem": { "message": "Відновити запис" @@ -3367,6 +3370,12 @@ "error": { "message": "Помилка" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Помилка розшифрування" }, @@ -4743,7 +4752,7 @@ } }, "moreOptionsLabelNoPlaceholder": { - "message": "More options" + "message": "Більше опцій" }, "moreOptionsTitle": { "message": "Інші можливості – $ITEMNAME$", @@ -5132,10 +5141,10 @@ } }, "showMatchDetectionNoPlaceholder": { - "message": "Show match detection" + "message": "Показати виявлення збігів" }, "hideMatchDetectionNoPlaceholder": { - "message": "Hide match detection" + "message": "Приховати виявлення збігів" }, "autoFillOnPageLoad": { "message": "Автоматично заповнювати під час завантаження сторінки?" @@ -5674,7 +5683,7 @@ "message": "Дуже широке" }, "narrow": { - "message": "Narrow" + "message": "Вузький" }, "sshKeyWrongPassword": { "message": "Ви ввели неправильний пароль." diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index d2a774782c9..fdac572e550 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "Đăng nhập bằng khóa truy cập" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, @@ -3367,6 +3370,12 @@ "error": { "message": "Lỗi" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "Lỗi giải mã" }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 40f1737574e..a4dee24b56a 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "使用通行密钥登录" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "使用单点登录" }, @@ -3155,10 +3158,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "tdeDisabledMasterPasswordRequired": { "message": "您的组织禁用了信任设备加密。要访问您的密码库,请设置一个主密码。" @@ -3367,6 +3370,12 @@ "error": { "message": "错误" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "解密错误" }, @@ -3381,7 +3390,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index b38b101efa9..540a4b053ff 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -28,6 +28,9 @@ "logInWithPasskey": { "message": "使用密碼金鑰登入" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, "useSingleSignOn": { "message": "使用單一登入" }, @@ -3367,6 +3370,12 @@ "error": { "message": "錯誤" }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + }, "decryptionError": { "message": "解密發生錯誤" }, From e03abdaed5c9811afdb701a54784a4cea7b710f2 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:41:05 +0100 Subject: [PATCH 33/52] Autosync the updated translations (#18558) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/pt_PT/messages.json | 2 +- apps/desktop/src/locales/zh_CN/messages.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 85e121c4b93..2eee6006d30 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -4407,7 +4407,7 @@ "message": "Desarquivar e guardar" }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "premiumSubscriptionEnded": { "message": "A sua subscrição Premium terminou" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 52f433f6b6d..1e7f860a65f 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -355,7 +355,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "january": { @@ -2568,10 +2568,10 @@ "message": "更新主密码" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,您必须立即更新它。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" @@ -3183,7 +3183,7 @@ "message": "请求获得批准后,您将收到通知" }, "needAnotherOption": { - "message": "必须在 Bitwarden App 的设置中启用设备登录。需要其他选项吗?" + "message": "必须在 Bitwarden App 的设置中设置设备登录。需要其他选项吗?" }, "viewAllLogInOptions": { "message": "查看所有登录选项" From 46266dfd20b44df6592094fd5abd6d33920a27a8 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:53:03 +0000 Subject: [PATCH 34/52] Autosync the updated translations (#18560) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/af/messages.json | 24 ++++++- apps/web/src/locales/ar/messages.json | 24 ++++++- apps/web/src/locales/az/messages.json | 24 ++++++- apps/web/src/locales/be/messages.json | 24 ++++++- apps/web/src/locales/bg/messages.json | 24 ++++++- apps/web/src/locales/bn/messages.json | 24 ++++++- apps/web/src/locales/bs/messages.json | 24 ++++++- apps/web/src/locales/ca/messages.json | 24 ++++++- apps/web/src/locales/cs/messages.json | 24 ++++++- apps/web/src/locales/cy/messages.json | 24 ++++++- apps/web/src/locales/da/messages.json | 24 ++++++- apps/web/src/locales/de/messages.json | 24 ++++++- apps/web/src/locales/el/messages.json | 24 ++++++- apps/web/src/locales/en_GB/messages.json | 24 ++++++- apps/web/src/locales/en_IN/messages.json | 24 ++++++- apps/web/src/locales/eo/messages.json | 24 ++++++- apps/web/src/locales/es/messages.json | 24 ++++++- apps/web/src/locales/et/messages.json | 24 ++++++- apps/web/src/locales/eu/messages.json | 24 ++++++- apps/web/src/locales/fa/messages.json | 24 ++++++- apps/web/src/locales/fi/messages.json | 24 ++++++- apps/web/src/locales/fil/messages.json | 24 ++++++- apps/web/src/locales/fr/messages.json | 24 ++++++- apps/web/src/locales/gl/messages.json | 24 ++++++- apps/web/src/locales/he/messages.json | 28 ++++++-- apps/web/src/locales/hi/messages.json | 24 ++++++- apps/web/src/locales/hr/messages.json | 24 ++++++- apps/web/src/locales/hu/messages.json | 24 ++++++- apps/web/src/locales/id/messages.json | 24 ++++++- apps/web/src/locales/it/messages.json | 24 ++++++- apps/web/src/locales/ja/messages.json | 24 ++++++- apps/web/src/locales/ka/messages.json | 24 ++++++- apps/web/src/locales/km/messages.json | 24 ++++++- apps/web/src/locales/kn/messages.json | 24 ++++++- apps/web/src/locales/ko/messages.json | 24 ++++++- apps/web/src/locales/lv/messages.json | 24 ++++++- apps/web/src/locales/ml/messages.json | 24 ++++++- apps/web/src/locales/mr/messages.json | 24 ++++++- apps/web/src/locales/my/messages.json | 24 ++++++- apps/web/src/locales/nb/messages.json | 24 ++++++- apps/web/src/locales/ne/messages.json | 24 ++++++- apps/web/src/locales/nl/messages.json | 42 ++++++++---- apps/web/src/locales/nn/messages.json | 24 ++++++- apps/web/src/locales/or/messages.json | 24 ++++++- apps/web/src/locales/pl/messages.json | 24 ++++++- apps/web/src/locales/pt_BR/messages.json | 24 ++++++- apps/web/src/locales/pt_PT/messages.json | 26 +++++-- apps/web/src/locales/ro/messages.json | 24 ++++++- apps/web/src/locales/ru/messages.json | 24 ++++++- apps/web/src/locales/si/messages.json | 24 ++++++- apps/web/src/locales/sk/messages.json | 24 ++++++- apps/web/src/locales/sl/messages.json | 24 ++++++- apps/web/src/locales/sr_CS/messages.json | 24 ++++++- apps/web/src/locales/sr_CY/messages.json | 24 ++++++- apps/web/src/locales/sv/messages.json | 44 ++++++++---- apps/web/src/locales/ta/messages.json | 24 ++++++- apps/web/src/locales/te/messages.json | 24 ++++++- apps/web/src/locales/th/messages.json | 24 ++++++- apps/web/src/locales/tr/messages.json | 24 ++++++- apps/web/src/locales/uk/messages.json | 58 ++++++++++------ apps/web/src/locales/vi/messages.json | 24 ++++++- apps/web/src/locales/zh_CN/messages.json | 56 +++++++++------ apps/web/src/locales/zh_TW/messages.json | 86 ++++++++++++++---------- 63 files changed, 1409 insertions(+), 275 deletions(-) diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index b717abbda7a..9ffb2bb3ffb 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Kies ’n plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 2f0ab382c7b..b54808089cd 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 2c0e30015e6..c272c48e2af 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -5626,13 +5626,13 @@ "message": "Send uğurla yaradıldı!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Bu Send keçidini kopyala və paylaş. Qeyd etdiyiniz şəxslər buna növbəti $TIME$ ərzində baxa bilər.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Seyf event verilərini Datadog serverinizə göndərin" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "İnteqrasiya saxlanılmadı. Lütfən daha sonra yenidən sınayın." }, @@ -10543,6 +10546,12 @@ "index": { "message": "İndeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Bir plan seçin" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "İndi doğrula." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Əlavə anbar sahəsi GB" }, diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 5167a966ac5..aa5d985a0c1 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index ae77e08a333..0d1d9b2527b 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -5626,13 +5626,13 @@ "message": "Изпращането е създадено успешно!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Копирайте и споделете връзка към това Изпращане. То ще може да бъде видяно само от хората, които сте посочили, в рамките на следващите $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Копирайте и споделете връзката към Изпращането. То ще бъде достъпно за всеки с връзката в рамките на следващите $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Изпращане на данните за събитията в трезора към Вашата инсталация на Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Изпращане на данни за събитията до Вашата инстанция на Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Интеграцията не беше запазена. Опитайте отново по-късно." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "Адрес на събирача на събития по HTTP" + }, + "httpEventCollectorToken": { + "message": "Идентификатор на събирача на събития по HTTP" + }, "selectAPlan": { "message": "Изберете план" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Потвърдете сега." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Допълнително място в ГБ" }, diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 803ea21169f..efed3069132 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 130fef41c29..1d4010331d8 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 642a67d93ea..c28b0bb4f35 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Seleccioneu un pla" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 046bf3e2fea..3f85b1641f2 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -5626,13 +5626,13 @@ "message": "Send byl úspěšně vytvořen!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Zkopírujte a sdílejte tento Send pro odesílání. Můžou jej zobrazit osoby, které jste zadali, a to po dobu $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Zkopírujte a sdílejte tento odkaz Send. Send bude k dispozici komukoli s odkazem na dalších $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Odeslat data o trezoru do Vaší instance Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Odešle data události do Vaší instanci SIEM Huntress" + }, "failedToSaveIntegration": { "message": "Nepodařilo se uložit integraci. Opakujte akci později." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "URL kolektoru HTTP událostí" + }, + "httpEventCollectorToken": { + "message": "Token kolektoru HTTP událostí" + }, "selectAPlan": { "message": "Vyberte plán" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Ověřit nyní" }, + "unlockWithPasskey": { + "message": "Odemknout pomocí přístupového klíče" + }, + "prfUnlockFailed": { + "message": "Nepodařilo se odemknout pomocí přístupového klíče. Zkuste to znovu nebo použijte jinou metodu odemknutí." + }, + "noPrfCredentialsAvailable": { + "message": "K odemknutí nejsou k dispozici žádné přístupové klíče s podporou PRF." + }, "additionalStorageGB": { "message": "Další úložiště (GB)" }, diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index dc637d23b13..a815d4b10a8 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 36f716ea94b..86c28faec3f 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Vælg en abonnementstype" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 647ebc8946e..00af564413c 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -5626,13 +5626,13 @@ "message": "Send erfolgreich erstellt!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Kopiere und teile diesen Send-Link. Er kann von den von dir angegebenen Personen für die nächsten $TIME$ angesehen werden.", + "sendCreatedDescriptionV2": { + "message": "Kopiere und teile diesen Send-Link. Das Send wird für jeden mit dem Link für die nächsten $TIME$ verfügbar sein.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Tresor-Ereignisdaten an deine Datadog-Instanz senden" }, + "huntressEventIntegrationDesc": { + "message": "Sende Ereignisdaten an deine Huntress SIEM-Instanz" + }, "failedToSaveIntegration": { "message": "Fehler beim Speichern der Integration. Bitte versuche es später erneut." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Ereignissammler-URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Ereignissammler-Token" + }, "selectAPlan": { "message": "Einen Tarif auswählen" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Jetzt verifizieren." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Zusätzlicher Speicher GB" }, diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index d6338f4b1a6..915063fa0cf 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Επιλογή προγράμματος" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index b6523df30b1..9132193cb87 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 35a5d72c012..0e2585e8f13 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 59e907c6dc3..388f094918d 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 41406744b2d..e53e6047f35 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Selecciona un plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 3dd2852cb5a..15546143435 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 7fc629306a2..34f7010daf8 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index dc46ab14304..6472c5ccc63 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "یک طرح انتخاب کنید" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index fa347ae1910..966051ae674 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Valitse tilaus" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 1d18e7d3b38..0d661ea8d13 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 3ed1616cf78..649c1bc5ea5 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -5626,13 +5626,13 @@ "message": "Send créé avec succès !", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copiez et partagez ce lien Send. Il peut être consulté par les personnes que vous avez spécifiées pour $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copiez et partagez ce lien Send. Le Send sera disponible à quiconque avec le lien pour les prochains $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Envoyer les données de l'événement du coffre à votre instance Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Envoyer les données de l'événement à votre instance de Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Impossible d'enregistrer l'intégration. Veuillez réessayer plus tard." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "URL HTTP du Collecterur d'Événements" + }, + "httpEventCollectorToken": { + "message": "Jeton du Collecteur d'Événements HTTP" + }, "selectAPlan": { "message": "Sélectionnez un plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Vérifier maintenant." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Stockage additionnel (Go)" }, diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index caf617cdaf8..9dfe84f39e7 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 9ee43cb029a..8dd55800a4b 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -302,7 +302,7 @@ } }, "atRiskMemberDescription": { - "message": "These members are logging into critical applications with weak, exposed, or reused passwords." + "message": "חברים אלה נכנסו אל יישומים עם סיסמאות חלשות, חשופות, או משומשות." }, "atRiskMembersDescriptionNone": { "message": "אין חברים שנכנסו אל יישומים עם סיסמאות חלשות, חשופות, או משומשות." @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -9395,7 +9395,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "memberDecryptionOptionTdeDescPart2": { - "message": "", + "message": "policy,", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Members will not need a master password when logging in with SSO. Master password is replaced with an encryption key stored on the device, making that device trusted. The first device a member creates their account and logs into will be trusted. New devices will need to be approved by an existing trusted device or by an administrator. The single organization policy, SSO required policy, and account recovery administration policy will turn on when this option is used.'" }, "memberDecryptionOptionTdeDescLink2": { @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "שלח נתוני אירועי כספת אל מופע ה־Datadog שלך" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "שמירת האינטגרציה נכשלה. נא לנסות שוב מאוחר יותר." }, @@ -10543,6 +10546,12 @@ "index": { "message": "אינדקס" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "בחר תוכנית" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "אמת כעת." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "אחסון נוסף ב־GB" }, diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 342672dafc7..96d4b188398 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 681a2b94fd9..77f322e57d8 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -5626,13 +5626,13 @@ "message": "Send je uspješno stvoren!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Pošalji podatke o događajima trezora svojoj Datadog instanci" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Spremanje integracije nije uspjelo. Pokušaj ponovno kasnije." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Indeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Odaberi plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Potvrdi sada." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Dodati GB pohrane" }, diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index d65fff35538..65818dcb059 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -5626,13 +5626,13 @@ "message": "A Send sikeresen létrejött!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Másoljuk és osszuk meg ezt a Send hivatkozást. Megtekinthetik a megadott személyek a következő $TIME$ intervallumban.", + "sendCreatedDescriptionV2": { + "message": "Másoljuk és osszuk meg ezt a Send elem hivatkozást. A Send elem bárki számára elérhető lesz, aki rendelkezik a hivatkozással a következő $TIME$ alatt.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Széf eseményadatok küldése a Datadog példánynak" }, + "huntressEventIntegrationDesc": { + "message": "Eseményadatok küldése a Huntress SIEM éldánynak" + }, "failedToSaveIntegration": { "message": "Nem sikerült menteni az integrációt. Próbáljuk újra később." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP eseménygyűjtő webcím" + }, + "httpEventCollectorToken": { + "message": "HTTP eseménygyűjtő vezérjel" + }, "selectAPlan": { "message": "Előfizetés kiválasztása" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Ellenőrzés most" }, + "unlockWithPasskey": { + "message": "Hozzáférési kulcs" + }, + "prfUnlockFailed": { + "message": "Nem sikerült a feloldás a hozzéférési kulccsal. Próbáljuk újra vagy használjunk más feloldási metódust." + }, + "noPrfCredentialsAvailable": { + "message": "A feloldáshoz nem állnak rendelkezésre PRF kompatibilis hozzáférési kucsok." + }, "additionalStorageGB": { "message": "Kiegészítő tárhely (GB)" }, diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 43cef248d13..96cbe0c9e8c 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index d025d5034c4..c57918dfb0f 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -5626,13 +5626,13 @@ "message": "Send creato con successo!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copia e condividi questo link Send: potrà essere visualizzato dalle persone che hai specificato per le prossime $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Invia i dati dell'evento della cassaforte all'istanza di Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Impossibile salvare l'integrazione. Riprova più tardi." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Indice" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Seleziona un piano" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verifica adesso." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Spazio di archiviazione aggiuntivo (GB)" }, diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 11e65d9d738..25ba0d15748 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "プランを選択" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index bb9d73493e2..cdc4d476edc 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 53229a365bb..c5a2ccd47f3 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index 39766ec7268..912649d6ac4 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 81eb7345714..c5d1293c528 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 53b7236b8de..13ef6411be7 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -5626,13 +5626,13 @@ "message": "Send tika veiksmīgi izveidots.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Ievieto starpliktuvē un kopīgo šī Send saiti! To $TIME$ no šī brīža var apskatīt cilvēki, kurus norādīji.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Nosūtīt glabātavas notikumu datus uz savu Datadog serveri" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Neizdevās saglabāt iekļaušanu. Lūgums vēlāk mēģināt vēlreiz." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Indekss" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Atlasīt plānu" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Apliecini tagad!" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Papildu krātuve GB" }, diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 9cbcf27107c..5bae262f5ba 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index da407d6e6dd..d0ef79397b1 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 53229a365bb..c5a2ccd47f3 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index d50f3d30f42..e9dca7aa77a 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index d879a6ca6cf..7e638d3ab8b 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index b537e42bba4..ac68180e886 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Kopieer en deel deze Send-link. De Send is beschikbaar voor iedereen met de link voor de volgende $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5916,35 +5916,35 @@ } }, "centralizeDataOwnership": { - "message": "Centralize organization ownership" + "message": "Centraliseer organisatie-eigendom" }, "centralizeDataOwnershipDesc": { - "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + "message": "Alle items van leden worden eigendom van en beheerd door de organisatie. Beheerders en eigenaren zijn vrijgesteld. " }, "centralizeDataOwnershipContentAnchor": { - "message": "Learn more about centralized ownership", + "message": "Meer informatie over gecentraliseerd eigendom", "description": "This will be used as a hyperlink" }, "benefits": { "message": "Voordelen" }, "centralizeDataOwnershipBenefit1": { - "message": "Gain full visibility into credential health, including shared and unshared items." + "message": "Krijg volledige zichtbaarheid in de gezondheid van inloggegevens, inclusief gedeelde en niet-gedeelde items." }, "centralizeDataOwnershipBenefit2": { - "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + "message": "Eenvoudig items tijdens het offboarden van leden en opvolging verplaatsen, verzekerd dat er geen toegangsgaten zijn." }, "centralizeDataOwnershipBenefit3": { - "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + "message": "Geef alle gebruikers een toegewijde \"Mijn Items\"-ruimte voor het beheren van hun eigen inloggegevens." }, "centralizeDataOwnershipWarningTitle": { - "message": "Prompt members to transfer their items" + "message": "Vraagt de leden om hun items over te brengen" }, "centralizeDataOwnershipWarningDesc": { - "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." + "message": "Als leden items in hun individuele kluis hebben, worden ze gevraagd deze over te dragen naar de organisatie of te vertrekken. Als ze vertrekken, wordt hun toegang ingetrokken maar kan deze op elk moment worden hersteld." }, "centralizeDataOwnershipWarningLink": { - "message": "Learn more about the transfer" + "message": "Meer informatie over de overstap" }, "organizationDataOwnership": { "message": "Gegevenseigendom van organisatie afdwingen" @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Stuur gebeurtenisgegevens van je kluis naar je Datadog-instance" }, + "huntressEventIntegrationDesc": { + "message": "Stuur eventgegevens naar je Huntress SIEM-instantie" + }, "failedToSaveIntegration": { "message": "Opslaan van integratie mislukt. Probeer het later opnieuw." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Selecteer een plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Nu verifiëren." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Extra opslagruimte (GB)" }, diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 626abb32cb7..ab58d48f3a2 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 53229a365bb..c5a2ccd47f3 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 7cb555cf6fa..90f180aa7fa 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Wybierz plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 2d4a3d72123..632d0c79b7b 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -5626,13 +5626,13 @@ "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copie e compartilhe este link do Send. Ele pode ser visto pelas pessoas que você especificou pelos próximos $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copie e compartilhe este link do Send. O Send ficará disponível para qualquer um com o link por $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Envie dados de eventos do cofre a sua instância do Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Envie dados de eventos para sua instância do Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Falha ao salvar a integração. Tente novamente mais tarde." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Índice" }, + "httpEventCollectorUrl": { + "message": "URL do coletor de eventos HTTP" + }, + "httpEventCollectorToken": { + "message": "Token do coletor de eventos HTTP" + }, "selectAPlan": { "message": "Selecione um plano" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verifique agora." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "GB de armazenamento adicional" }, diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 43012a2ab3f..c99bd97d750 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -3156,7 +3156,7 @@ "message": "O item foi restaurado" }, "restartPremium": { - "message": "Reiniciar Premium" + "message": "Reiniciar o Premium" }, "additionalStorageGb": { "message": "Armazenamento adicional (GB)" @@ -5626,13 +5626,13 @@ "message": "Send criado com sucesso!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copie e partilhe este link do Send. Pode ser visualizado pelas pessoas que especificou durante os próximos $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copie e partilhe este link do Send. O Send estará disponível para qualquer pessoa com o link durante os próximos $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Envie dados de eventos do cofre para a sua instância da Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Enviar dados de eventos para a sua instância Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Falha ao guardar a integração. Por favor, tente novamente mais tarde." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Índice" }, + "httpEventCollectorUrl": { + "message": "URL do coletor de eventos HTTP" + }, + "httpEventCollectorToken": { + "message": "Token do coletor de eventos HTTP" + }, "selectAPlan": { "message": "Selecionar um plano" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verificar agora." }, + "unlockWithPasskey": { + "message": "Desbloquear com chave de acesso" + }, + "prfUnlockFailed": { + "message": "Não foi possível desbloquear com a chave de acesso. Por favor, tente novamente ou utilize outro método de desbloqueio." + }, + "noPrfCredentialsAvailable": { + "message": "Não estão disponíveis chaves de acesso com PRF ativado para o desbloqueio." + }, "additionalStorageGB": { "message": "Armazenamento adicional (GB)" }, diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index e46e8ddcb5b..112b058d80b 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 93670efc081..0963c04140d 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -5626,13 +5626,13 @@ "message": "Send успешно создана!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Скопируйте и распространите эту ссылку для Send. Она может быть просмотрена указанными вами пользователями в следующие $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Скопируйте и поделитесь этой ссылкой Send. Send будет доступна всем, у кого есть ссылка, в течение следующих $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Отправляйте данные о событиях хранилища в ваш экземпляр Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Отправлять данные о событиях в ваш инстанс Huntress SIEM" + }, "failedToSaveIntegration": { "message": "Не удалось сохранить интеграцию. Пожалуйста, повторите попытку позже." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "URL коллектора событий HTTP" + }, + "httpEventCollectorToken": { + "message": "Токен коллектора событий HTTP" + }, "selectAPlan": { "message": "Выберите план" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Подтвердить сейчас." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Дополнительные ГБ хранилища" }, diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 95b0e61e822..5f3f4974bd5 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 5ec513ff48d..459b0b28973 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -5626,13 +5626,13 @@ "message": "Send bol úspešne vytvorený!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Skopírujte a zdieľajte tento odkaz na Send. Ľudia ktorých ste zadali môžu Send vidieť najbližších $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Pošlite dáta z denníka udalostí do vašej inštancie Datadog" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Nepodarilo sa uložiť integráciu. Prosím skúste to neskôr." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Vyberte plán" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Overiť teraz." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Dodatočné úložisko GB" }, diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 12734f2fb8a..89d96c07bb7 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 18202bbc87a..16d728c73c4 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index 7b7876ce303..078b342048f 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Није успело сачувавање интеграције. Покушајте поново касније." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Индекс" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Изаберите пакет" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index a7ca072cca6..e73afc42759 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -5422,7 +5422,7 @@ "message": "Arkiverat objekt återställt" }, "archivedItemsRestored": { - "message": "Archived items restored" + "message": "Arkiverade objekt återställda" }, "restoredItem": { "message": "Återställde objekt" @@ -5626,13 +5626,13 @@ "message": "Send skapades!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Kopiera och dela denna Send-länk. Den kan visas av personer som du har angivet nästa $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Kopiera och dela denna Send-länk. Denna Send kommer att vara tillgänglig för alla med länken för nästa $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -5916,13 +5916,13 @@ } }, "centralizeDataOwnership": { - "message": "Centralize organization ownership" + "message": "Centralisera organisationens ägarskap" }, "centralizeDataOwnershipDesc": { - "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + "message": "Alla medlemsobjekt kommer att ägas och hanteras av organisationen. Administratörer och ägare är undantagna. " }, "centralizeDataOwnershipContentAnchor": { - "message": "Learn more about centralized ownership", + "message": "Läs mer om centraliserat ägarskap", "description": "This will be used as a hyperlink" }, "benefits": { @@ -5935,16 +5935,16 @@ "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." }, "centralizeDataOwnershipBenefit3": { - "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + "message": "Ge alla användare ett dedikerat \"Mina objekt\"-utrymme för att hantera sina egna inloggningar." }, "centralizeDataOwnershipWarningTitle": { - "message": "Prompt members to transfer their items" + "message": "Fråga medlemmar att överföra sina objekt" }, "centralizeDataOwnershipWarningDesc": { "message": "If members have items in their individual vault, they will be prompted to either transfer them to the organization or leave. If they leave, their access is revoked but can be restored anytime." }, "centralizeDataOwnershipWarningLink": { - "message": "Learn more about the transfer" + "message": "Läs mer om överföringen" }, "organizationDataOwnership": { "message": "Genomför äganderätt till organisationsdata" @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Skicka data om valvhändelser till din Datadog-instans" }, + "huntressEventIntegrationDesc": { + "message": "Skicka händelsedata till din Huntress SIEM-instans" + }, "failedToSaveIntegration": { "message": "Misslyckades med att spara integration. Försök igen senare." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Välj en plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verifiera nu." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Ytterligare lagringsplats (GB)" }, @@ -12643,7 +12661,7 @@ "message": "Lagringen är full" }, "storageUsedDescription": { - "message": "You have used $USED$ out of $AVAILABLE$ GB of your encrypted file storage.", + "message": "Du har använt $USED$ av $AVAILABLE$ GB av din krypterade fillagring.", "placeholders": { "used": { "content": "$1", @@ -12656,10 +12674,10 @@ } }, "storageFullDescription": { - "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." + "message": "Du har använt alla $GB$ GB av din krypterade lagring. För att fortsätta lagra filer, lägg till mer lagringsutrymme." }, "whenYouRemoveStorage": { - "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." + "message": "När du tar bort lagring kommer du att få en proportionell kontokredit som automatiskt går mot din nästa faktura." }, "youHavePremium": { "message": "Du har Premium" diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 7902bb19e02..931fd3be2f9 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "ஒருங்கிணைப்பைச் சேமிக்கத் தவறிவிட்டது. பின்னர் மீண்டும் முயற்சிக்கவும்." }, @@ -10543,6 +10546,12 @@ "index": { "message": "குறியீடு" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "ஒரு திட்டத்தைத் தேர்ந்தெடுக்கவும்" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 53229a365bb..c5a2ccd47f3 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index c8fcb955fd8..48a0e043a4b 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Select a plan" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 52dae5e86ac..c356289ab50 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -5626,13 +5626,13 @@ "message": "Send başarıyla oluşturuldu.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Bu Send bağlantısını kopyalayıp paylaşın. Belirlediğiniz kişiler bağlantıyı önümüzdeki $TIME$ boyunca kullanabilir.", + "sendCreatedDescriptionV2": { + "message": "Bu Send bağlantısını kopyalayıp paylaşın. Bu Send'e önümüzdeki $TIME$ boyunca bağlantıya sahip herkes ulaşabilecektir.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Kasa olay verilerini Datadog örneğinize gönderin" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Entegrasyon kaydedilemedi. Lütfen daha sonra tekrar deneyin." }, @@ -10543,6 +10546,12 @@ "index": { "message": "İndeks" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Bir plan seçin" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Şimdi doğrulayın." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Ek depolama alanı GB" }, diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 2f170f54750..0e8190479eb 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -3,7 +3,7 @@ "message": "Всі програми" }, "activity": { - "message": "Activity" + "message": "Активність" }, "appLogoLabel": { "message": "Логотип Bitwarden" @@ -134,7 +134,7 @@ "message": "critical applications marked" }, "countOfCriticalApplications": { - "message": "$COUNT$ critical applications", + "message": "$COUNT$ критичних програм", "placeholders": { "count": { "content": "$1", @@ -179,7 +179,7 @@ } }, "noDataInOrgTitle": { - "message": "No data found" + "message": "Дані не знайдено" }, "noDataInOrgDescription": { "message": "Import your organization's login data to get started with Access Intelligence. Once you do that, you'll be able to:" @@ -209,13 +209,13 @@ "message": "You’re ready to start generating reports. Once you generate, you’ll be able to:" }, "noCriticalApplicationsTitle": { - "message": "Ви не відмітили жодного додатку в якості критичного" + "message": "Ви не позначили жодної програми в якості критичної" }, "noCriticalApplicationsDescription": { "message": "Select your most critical applications to prioritize security actions for your users to address at-risk passwords." }, "markCriticalApplications": { - "message": "Вибрати критичні додатки" + "message": "Вибрати критичні програми" }, "markAppAsCritical": { "message": "Позначити програму критичною" @@ -224,13 +224,13 @@ "message": "Mark as critical" }, "applicationsSelected": { - "message": "applications selected" + "message": "програм обрано" }, "selectApplication": { - "message": "Select application" + "message": "Обрати програму" }, "unselectApplication": { - "message": "Unselect application" + "message": "Скасувати вибір програми" }, "applicationsMarkedAsCriticalSuccess": { "message": "Позначені критичні програми" @@ -344,10 +344,10 @@ "message": "Applications needing review" }, "newApplicationsCardTitle": { - "message": "Review new applications" + "message": "Перегляд нових програм" }, "newApplicationsWithCount": { - "message": "$COUNT$ new applications", + "message": "$COUNT$ нових програм", "placeholders": { "count": { "content": "$1", @@ -380,7 +380,7 @@ "message": "Review applications to secure the items most critical to your organization's security" }, "reviewApplications": { - "message": "Review applications" + "message": "Перегляд програм" }, "prioritizeCriticalApplications": { "message": "Prioritize critical applications" @@ -404,7 +404,7 @@ "message": "Application review saved" }, "newApplicationsReviewed": { - "message": "New applications reviewed" + "message": "Переглянуто нові програми" }, "errorSavingReviewStatus": { "message": "Error saving review status" @@ -5626,13 +5626,13 @@ "message": "Send created successfully!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Send vault event data to your Datadog instance" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Failed to save integration. Please try again later." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Index" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Оберіть тарифний план" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Verify now." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "Additional storage GB" }, @@ -12379,7 +12397,7 @@ "message": "If you don't verify your organization, your access to the organization will be revoked." }, "leaveNow": { - "message": "Leave now" + "message": "Покинути зараз" }, "verifyYourDomainToLogin": { "message": "Verify your domain to log in" @@ -12489,7 +12507,7 @@ "message": "Set an unlock method to change your timeout action" }, "leaveConfirmationDialogTitle": { - "message": "Are you sure you want to leave?" + "message": "Ви дійсно хочете покинути?" }, "leaveConfirmationDialogContentOne": { "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." @@ -12498,7 +12516,7 @@ "message": "Contact your admin to regain access." }, "leaveConfirmationDialogConfirmButton": { - "message": "Leave $ORGANIZATION$", + "message": "Покинути $ORGANIZATION$", "placeholders": { "organization": { "content": "$1", @@ -12531,7 +12549,7 @@ "message": "Accept transfer" }, "declineAndLeave": { - "message": "Decline and leave" + "message": "Відхилити та покинути" }, "whyAmISeeingThis": { "message": "Why am I seeing this?" @@ -12579,7 +12597,7 @@ "message": "Open the subscription page on your Bitwarden cloud account and download your license file. Then return to this screen and upload it below." }, "viewAllPlans": { - "message": "View all plans" + "message": "Переглянути всі тарифні плани" }, "planDescPremium": { "message": "Complete online security" diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index d7d3706f3ec..c64c98d3453 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -5626,13 +5626,13 @@ "message": "Đã tạo Send thành công!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "Copy and share this Send link. It can be viewed by the people you specified for the next $TIME$.", + "sendCreatedDescriptionV2": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "Gửi dữ liệu sự kiện kho bảo mật đến phiên bản Datadog của bạn" }, + "huntressEventIntegrationDesc": { + "message": "Send event data to your Huntress SIEM instance" + }, "failedToSaveIntegration": { "message": "Không thể lưu tích hợp. Vui lòng thử lại sau." }, @@ -10543,6 +10546,12 @@ "index": { "message": "Mục lục" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "Chọn một gói" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "Xác minh ngay." }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "GB lưu trữ bổ sung" }, diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 783d0d5bef7..bf56f05c084 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -1375,7 +1375,7 @@ "message": "使用设备登录" }, "loginWithDeviceEnabledNote": { - "message": "必须在 Bitwarden App 的设置中启用设备登录。需要其他选项吗?" + "message": "必须在 Bitwarden App 的设置中设置设备登录。需要其他选项吗?" }, "needAnotherOptionV1": { "message": "需要其他选项吗?" @@ -1829,7 +1829,7 @@ "message": "登录不可用" }, "noTwoStepProviders": { - "message": "此账户已启用两步登录,但此浏览器不支持任何已配置的两步登录提供程序。" + "message": "此账户已设置两步登录,但此浏览器不支持任何已配置的两步登录提供程序。" }, "noTwoStepProviders2": { "message": "请使用受支持的网页浏览器(例如 Chrome),和/或添加其他跨网页浏览器支持更好的提供程序(例如验证器 App)。" @@ -2140,7 +2140,7 @@ } }, "loggedOutWarning": { - "message": "继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" @@ -2225,7 +2225,7 @@ "message": "您是否担心自己的账户在其他设备上登录过?继续下面的操作以取消对之前使用过的所有计算机或设备的授权。如果您以前使用过公共计算机或不小心曾将密码保存在不属于您的设备上,则建议执行此安全步骤。此步骤还将清除所有以前记住的两步登录会话。" }, "deauthorizeSessionsWarning": { - "message": "继续操作还将使您退出当前会话,并要求您重新登录。如果有设置两步登录,也需要重新验证。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "继续操作还将使您注销当前会话,并要求您重新登录。如果有设置两步登录,也需要重新验证。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "newDeviceLoginProtection": { "message": "新设备登录" @@ -2671,7 +2671,7 @@ "message": "保存表单。" }, "twoFactorYubikeyWarning": { - "message": "由于平台限制,YubiKey 不能在所有 Bitwarden 应用程序上使用。您应该启用另一个两步登录提供程序,以便在无法使用 YubiKey 时可以访问您的账户。支持的平台:" + "message": "由于平台限制,YubiKey 不能在所有 Bitwarden 应用程序上使用。您应该设置其他两步登录提供程序,以便在无法使用 YubiKey 时可以访问您的账户。支持的平台:" }, "twoFactorYubikeySupportUsb": { "message": "具有可使用 YubiKey 的 USB 端口的设备上的网页密码库、桌面应用程序、CLI 以及浏览器扩展。" @@ -2689,7 +2689,7 @@ } }, "u2fkeyX": { - "message": "U2F Key $INDEX$", + "message": "U2F 密钥 $INDEX$", "placeholders": { "index": { "content": "$1", @@ -2710,7 +2710,7 @@ "message": "NFC 支持" }, "twoFactorYubikeySupportsNfc": { - "message": "我的一把密钥支持 NFC。" + "message": "我的某个密钥支持 NFC。" }, "twoFactorYubikeySupportsNfcDesc": { "message": "如果您的某个 YubiKey 支持 NFC(例如 YubiKey NEO),移动设备在检测到 NFC 可用时将提示您。" @@ -2773,7 +2773,7 @@ "message": "保存表单。" }, "twoFactorU2fWarning": { - "message": "由于平台限制,FIDO U2F 不能在所有 Bitwarden 应用程序上使用。您应该启用另一个两步登录提供程序,以便在无法使用 FIDO U2F 时可以访问您的账户。支持的平台:" + "message": "由于平台限制,FIDO U2F 不能在所有 Bitwarden 应用程序上使用。您应该设置其他两步登录提供程序,以便在无法使用 FIDO U2F 时可以访问您的账户。支持的平台:" }, "twoFactorU2fSupportWeb": { "message": "桌面/笔记本电脑上支持 U2F 的浏览器(启用了 FIDO U2F 的 Chrome、Opera、Vivaldi 或 Firefox)中的网页密码库和浏览器扩展。" @@ -2791,7 +2791,7 @@ "message": "您的 Bitwarden 两步登录恢复代码" }, "twoFactorRecoveryNoCode": { - "message": "您尚未设置任何两步登录提供程序。在启用了一个两步登录提供程序后,请返回这里检查恢复代码。" + "message": "您尚未设置任何两步登录提供程序。在设置了一个两步登录提供程序后,请返回这里检查恢复代码。" }, "printCode": { "message": "打印代码", @@ -2837,7 +2837,7 @@ "message": "未激活两步登录" }, "inactive2faReportDesc": { - "message": "两步登录为您的账户增加了一层保护。使用 Bitwarden Authenticator 或其他方式为这些账户开启两步登录。" + "message": "两步登录为您的账户增加了一层保护。使用 Bitwarden Authenticator 或其他方式为这些账户设置两步登录。" }, "inactive2faFound": { "message": "发现未启用两步登录的登录项目" @@ -5626,13 +5626,13 @@ "message": "Send 创建成功!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "复制并分享此 Send 链接。您指定的人员可在接下来的 $TIME$ 内查看此 Send。", + "sendCreatedDescriptionV2": { + "message": "复制并分享此 Send 链接。在接下来的 $TIME$ 内,任何人都可以通过链接访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6448,7 +6448,7 @@ "message": "重置密码" }, "resetPasswordLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作将使 $NAME$ 注销当前会话,并要求他们重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。", "placeholders": { "name": { "content": "$1", @@ -6457,7 +6457,7 @@ } }, "emergencyAccessLoggedOutWarning": { - "message": "继续操作会将 $NAME$ 登出当前会话,并要求他们重新登录。在其他设备上的活动会话可能继续活动长达一个小时。", + "message": "继续操作将使 $NAME$ 注销当前会话,并要求他们重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。", "placeholders": { "name": { "content": "$1", @@ -6631,7 +6631,7 @@ "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "以避免额外的数据丢失。", + "message": "以避免进一步的数据丢失。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "accountRecoveryManageUsers": { @@ -6781,13 +6781,13 @@ "message": "您的主密码不符合本组织的要求。更改您的主密码以继续。" }, "updateMasterPasswordWarning": { - "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码最近被您组织的管理员更改过。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "masterPasswordInvalidWarning": { - "message": "您的主密码不符合此组织的策略要求。要加入此组织,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合此组织的策略要求。要加入此组织,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "updateWeakMasterPasswordWarning": { - "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" + "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您注销当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "automaticAppLoginWithSSO": { "message": "使用 SSO 自动登录" @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "将密码库事件数据发送到您的 Datadog 实例" }, + "huntressEventIntegrationDesc": { + "message": "将事件数据发送到您的 Huntress SIEM 实例" + }, "failedToSaveIntegration": { "message": "保存集成失败。请稍后再试。" }, @@ -10543,6 +10546,12 @@ "index": { "message": "索引" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "选择一个方案" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "立即验证。" }, + "unlockWithPasskey": { + "message": "使用通行密钥解锁" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "附加存储 GB" }, diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 598a712e8c7..49a1455dbfd 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -12,10 +12,10 @@ "message": "重要應用程式" }, "noCriticalAppsAtRisk": { - "message": "沒有關鍵應用程式處於風險中" + "message": "目前沒有任何關鍵應用程式存在風險" }, "accessIntelligence": { - "message": "存取資訊" + "message": "Access Intelligence" }, "passwordRisk": { "message": "密碼風險" @@ -24,13 +24,13 @@ "message": "你沒有權限編輯這個項目" }, "reviewAtRiskPasswords": { - "message": "檢視全部應用中具有風險的密碼 (弱、被暴露或重複使用)。選擇最重要的應用程式並優先採取安全措施,幫助使用者解決具有風險的密碼。" + "message": "檢視各應用程式中的風險密碼(弱、外洩或重複使用)。選擇最關鍵的應用程式,優先採取安全措施,協助使用者處理這些密碼。" }, "reviewAtRiskLoginsPrompt": { "message": "檢視有風險的登入資訊" }, "dataLastUpdated": { - "message": "上次資料更新日期:$DATE$", + "message": "資料最後更新於:$DATE$", "placeholders": { "date": { "content": "$1", @@ -42,7 +42,7 @@ "message": "您尚未建立報告" }, "notifiedMembers": { - "message": "已被通知的成員" + "message": "已通知成員" }, "revokeMembers": { "message": "撤銷成員" @@ -63,7 +63,7 @@ } }, "createNewLoginItem": { - "message": "新增登入項目" + "message": "建立新的登入項目" }, "percentageCompleted": { "message": "完成 $PERCENT$%", @@ -91,7 +91,7 @@ "message": "密碼變更進度" }, "assignMembersTasksToMonitorProgress": { - "message": "指派成員任務以監控進度" + "message": "指派任務給成員以監控進度" }, "onceYouReviewApplications": { "message": "當您審查應用程式並將其標記為關鍵後,可指派任務給成員以變更其密碼。" @@ -131,7 +131,7 @@ } }, "criticalApplicationsMarked": { - "message": "已將應用程式標記為關鍵" + "message": "已標記為關鍵的應用程式" }, "countOfCriticalApplications": { "message": "$COUNT$ 個關鍵應用程式", @@ -170,7 +170,7 @@ } }, "notifiedMembersWithCount": { - "message": "已被通知的成員($COUNT$)", + "message": "已通知成員 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -182,7 +182,7 @@ "message": "找不到資料" }, "noDataInOrgDescription": { - "message": "匯入您組織的登入資料以開始使用存取智慧功能。完成後,您將能夠:" + "message": "匯入組織的登入資料即可開始使用 Access Intelligence。完成後,您將能夠:" }, "feature1Title": { "message": "將應用程式標記為關鍵" @@ -197,7 +197,7 @@ "message": "指派有風險的成員執行指導式安全任務以更新憑證。" }, "feature3Title": { - "message": "監控進展" + "message": "追蹤進度" }, "feature3Description": { "message": "追蹤隨時間變化的狀況以顯示安全性改善。" @@ -514,16 +514,16 @@ } }, "websiteAdded": { - "message": "網站已添加" + "message": "網站已新增" }, "addWebsite": { - "message": "添加網站" + "message": "新增網站" }, "deleteWebsite": { "message": "刪除網站" }, "defaultLabel": { - "message": "預設 ($VALUE$)", + "message": "預設($VALUE$)", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -533,7 +533,7 @@ } }, "showMatchDetection": { - "message": "顯示偵測到的吻合 $WEBSITE$", + "message": "顯示偵測到相符的 $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -542,7 +542,7 @@ } }, "hideMatchDetection": { - "message": "隱藏偵測到的吻合 $WEBSITE$", + "message": "隱藏偵測到相符的 $WEBSITE$", "placeholders": { "website": { "content": "$1", @@ -560,7 +560,7 @@ "message": "發卡組織" }, "expiration": { - "message": "逾期" + "message": "到期日" }, "securityCode": { "message": "安全碼 (CVV)" @@ -581,7 +581,7 @@ "message": "護照號碼" }, "licenseNumber": { - "message": "許可證號碼" + "message": "駕照號碼" }, "email": { "message": "電子郵件" @@ -650,10 +650,10 @@ "message": "如果您已續卡,請更新支付卡資訊" }, "expirationMonth": { - "message": "逾期月份" + "message": "到期月份" }, "expirationYear": { - "message": "逾期年份" + "message": "到期年份" }, "authenticatorKeyTotp": { "message": "驗證器金鑰 (TOTP)" @@ -668,7 +668,7 @@ "message": "Bitwarden 可以儲存並填入兩步驟驗證碼。選擇相機圖示來截取此網站的驗證器QR code,或手動複製金鑰並貼上到此欄位。" }, "learnMoreAboutAuthenticators": { - "message": "了解更多驗證程式" + "message": "瞭解更多關於驗證器的資訊" }, "folder": { "message": "資料夾" @@ -705,7 +705,7 @@ "message": "未指派" }, "noneFolder": { - "message": "預設資料夾", + "message": "無資料夾", "description": "This is the folder for uncategorized items" }, "selfOwnershipLabel": { @@ -766,11 +766,11 @@ "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { - "message": "一致性偵測", + "message": "比對偵測", "description": "URI match detection for auto-fill." }, "defaultMatchDetection": { - "message": "預設一致性偵測", + "message": "預設比對偵測", "description": "Default URI match detection for auto-fill." }, "never": { @@ -1153,7 +1153,7 @@ "message": "複製護照號碼" }, "copyLicenseNumber": { - "message": "複製許可證號碼" + "message": "複製駕照號碼" }, "copyPrivateKey": { "message": "複製私密金鑰" @@ -1656,7 +1656,7 @@ "message": "發生了未預期的錯誤。" }, "expirationDateError": { - "message": "請選擇一個未來的逾期日期。" + "message": "請選擇一個未來的到期日。" }, "emailAddress": { "message": "電子郵件地址" @@ -2791,7 +2791,7 @@ "message": "您的 Bitwarden 兩步驟登入復原碼" }, "twoFactorRecoveryNoCode": { - "message": "您尚未啟用任何兩步驟登入方式。等你啟用兩步驟登入方式後,您可回來這裡取得復原碼。" + "message": "您目前尚未啟用任何兩步驟登入方式。啟用後,即可回到此處取得復原碼。" }, "printCode": { "message": "列印代碼", @@ -3845,7 +3845,7 @@ "message": "全部" }, "addAccess": { - "message": "添加存取權限" + "message": "新增存取權限" }, "addAccessFilter": { "message": "新增存取過濾器" @@ -4504,7 +4504,7 @@ "message": "更新瀏覽器" }, "generatingYourAccessIntelligence": { - "message": "正在產生您的存取智慧分析…" + "message": "正在產生您的 Access Intelligence……" }, "fetchingMemberData": { "message": "正在擷取成員資料…" @@ -5626,13 +5626,13 @@ "message": "Send 建立成功!", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendCreatedDescription": { - "message": "複製並分享此 Send 連結。在接下來的 $TIME$ 內,您指定的人員都可以檢視此內容。", + "sendCreatedDescriptionV2": { + "message": "複製並分享此 Send 連結。任何擁有此連結的人,都可在接下來的 $TIME$ 內存取該 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { "content": "$1", - "example": "7 days" + "example": "7 days, 1 hour, 1 day" } } }, @@ -6092,13 +6092,13 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "uriMatchDetectionPolicy": { - "message": "預設的 URI 一致性偵測方式" + "message": "預設 URI 相符偵測" }, "uriMatchDetectionPolicyDesc": { "message": "決定何時建議登入項目進行自動填入。管理員與擁有者不受此原則限制。" }, "uriMatchDetectionOptionsLabel": { - "message": "預設的 URI 一致性偵測方式" + "message": "預設 URI 相符偵測" }, "invalidUriMatchDefaultPolicySetting": { "message": "請選擇有效的 URI 比對偵測選項。", @@ -10432,6 +10432,9 @@ "datadogEventIntegrationDesc": { "message": "將密碼庫事件資料傳送至你的 Datadog 執行個體" }, + "huntressEventIntegrationDesc": { + "message": "將事件資料傳送至您的 SIEM 執行個體" + }, "failedToSaveIntegration": { "message": "整合設定儲存失敗。請稍後再試。" }, @@ -10543,6 +10546,12 @@ "index": { "message": "索引" }, + "httpEventCollectorUrl": { + "message": "HTTP Event Collector URL" + }, + "httpEventCollectorToken": { + "message": "HTTP Event Collector Token" + }, "selectAPlan": { "message": "選擇一個計劃" }, @@ -12092,6 +12101,15 @@ "verifyNow": { "message": "立即驗證" }, + "unlockWithPasskey": { + "message": "Unlock with passkey" + }, + "prfUnlockFailed": { + "message": "Failed to unlock with passkey. Please try again or use another unlock method." + }, + "noPrfCredentialsAvailable": { + "message": "No PRF-enabled passkeys are available for unlock." + }, "additionalStorageGB": { "message": "額外儲存空間 (GB)" }, From 94c40b53aa707d01d8a9deda178a2facca99d0ce Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 26 Jan 2026 08:05:46 -0600 Subject: [PATCH 35/52] PM-30799 added html clean up for the domain (#18393) --- apps/web/src/app/core/event.service.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index c8c6a54f2a6..36afd1850e0 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -522,16 +522,25 @@ export class EventService { break; // Org Domain claiming events case EventType.OrganizationDomain_Added: - msg = humanReadableMsg = this.i18nService.t("addedDomain", ev.domainName); + msg = humanReadableMsg = this.i18nService.t("addedDomain", this.escapeHtml(ev.domainName)); break; case EventType.OrganizationDomain_Removed: - msg = humanReadableMsg = this.i18nService.t("removedDomain", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + "removedDomain", + this.escapeHtml(ev.domainName), + ); break; case EventType.OrganizationDomain_Verified: - msg = humanReadableMsg = this.i18nService.t("domainClaimedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + "domainClaimedEvent", + this.escapeHtml(ev.domainName), + ); break; case EventType.OrganizationDomain_NotVerified: - msg = humanReadableMsg = this.i18nService.t("domainNotClaimedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + "domainNotClaimedEvent", + this.escapeHtml(ev.domainName), + ); break; // Secrets Manager case EventType.Secret_Retrieved: @@ -893,6 +902,15 @@ export class EventService { return id?.substring(0, 8); } + private escapeHtml(unsafe: string): string { + if (!unsafe) { + return unsafe; + } + const div = document.createElement("div"); + div.textContent = unsafe; + return div.innerHTML; + } + private toDateTimeLocalString(date: Date) { return ( date.getFullYear() + From 082bbd716f6e9ae7c1d787222e7c29058b813892 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:15:59 -0500 Subject: [PATCH 36/52] [deps]: Update Minor github-actions updates (#18434) * [deps]: Update Minor github-actions updates * Revert update of actions/create-github-app-token in test-browser-interactions.yml --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- .github/workflows/build-browser.yml | 2 +- .github/workflows/build-desktop.yml | 8 ++++---- .github/workflows/build-web.yml | 8 ++++---- .github/workflows/crowdin-pull.yml | 2 +- .github/workflows/lint-crowdin-config.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish-desktop.yml | 2 +- .github/workflows/publish-web.yml | 2 +- .github/workflows/repository-management.yml | 4 ++-- .github/workflows/sdk-breaking-change-check.yml | 2 +- .github/workflows/test.yml | 2 +- .github/workflows/version-auto-bump.yml | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 7614fdba396..7b35baf01e2 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -565,7 +565,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 701e6208b60..0d4009e54f9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1007,7 +1007,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1247,7 +1247,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1522,7 +1522,7 @@ jobs: node-version: ${{ env._NODE_VERSION }} - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: '3.14.2' @@ -1873,7 +1873,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index e626b629f5c..7b92de0f22a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -204,7 +204,7 @@ jobs: ########## Set up Docker ########## - name: Set up Docker - uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4.5.0 + uses: docker/setup-docker-action@e43656e248c0bd0647d3f5c195d116aacf6fcaf4 # v4.7.0 with: daemon-config: | { @@ -218,7 +218,7 @@ jobs: uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 ########## ACRs ########## - name: Log in to Azure @@ -334,7 +334,7 @@ jobs: - name: Scan Docker image if: ${{ needs.setup.outputs.has_secrets == 'true' }} id: container-scan - uses: anchore/scan-action@568b89d27fc18c60e56937bff480c91c772cd993 # v7.1.0 + uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3 with: image: ${{ steps.image-name.outputs.name }} fail-build: false @@ -390,7 +390,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Upload Sources - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index e99034c499a..a707fef0889 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -49,7 +49,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/lint-crowdin-config.yml b/.github/workflows/lint-crowdin-config.yml index dff253a8da2..61e2b3631e6 100644 --- a/.github/workflows/lint-crowdin-config.yml +++ b/.github/workflows/lint-crowdin-config.yml @@ -45,7 +45,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Lint ${{ matrix.app.name }} config - uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 + uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ matrix.app.project_id }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 83c931b4fe0..81d79df569c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -142,7 +142,7 @@ jobs: run: cargo +nightly udeps --workspace --all-features --all-targets - name: Install cargo-deny - uses: taiki-e/install-action@073d46cba2cde38f6698c798566c1b3e24feeb44 # v2.62.67 + uses: taiki-e/install-action@2e9d707ef49c9b094d45955b60c7e5c0dfedeb14 # v2.66.5 with: tool: cargo-deny@0.18.6 diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index f013abbbb3b..c5db7ea9295 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -331,7 +331,7 @@ jobs: run: wget "https://github.com/bitwarden/clients/releases/download/${_RELEASE_TAG}/macos-build-number.json" - name: Setup Ruby and Install Fastlane - uses: ruby/setup-ruby@d5126b9b3579e429dd52e51e68624dda2e05be25 # v1.267.0 + uses: ruby/setup-ruby@708024e6c902387ab41de36e1669e43b5ee7085e # v1.283.0 with: ruby-version: '3.4.7' bundler-cache: false diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index be0087800f7..c45e249d083 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -182,7 +182,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 65607268cda..33b4df24d7a 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -105,7 +105,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} @@ -485,7 +485,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index ecc803ebd5c..765e900af5c 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -53,7 +53,7 @@ jobs: secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d543b5287b5..eedf991d826 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: run: npm test -- --coverage --maxWorkers=3 - name: Report test results - uses: dorny/test-reporter@7b7927aa7da8b82e81e755810cb51f39941a2cc7 # v2.2.0 + uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index d66c48fcf58..2aba68c45a9 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -31,7 +31,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} From 47a2f5978485c0234c53e843781b8ca9e587d8d5 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Mon, 26 Jan 2026 10:19:51 -0500 Subject: [PATCH 37/52] [PM-31188] Desktop Trash Items Context Menu Updates (#18530) * apply isDeleted check to other options in desktop context menu for items --- .../src/vault/app/vault/vault-v2.component.ts | 143 +++++++++--------- 1 file changed, 73 insertions(+), 70 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index efbdee97798..fe2914216a3 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -642,77 +642,80 @@ export class VaultV2Component }); } - switch (cipher.type) { - case CipherType.Login: - if ( - cipher.login.canLaunch || - cipher.login.username != null || - cipher.login.password != null - ) { - menu.push({ type: "separator" }); - } - if (cipher.login.canLaunch) { - menu.push({ - label: this.i18nService.t("launch"), - click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), - }); - } - if (cipher.login.username != null) { - menu.push({ - label: this.i18nService.t("copyUsername"), - click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), - }); - } - if (cipher.login.password != null && cipher.viewPassword) { - menu.push({ - label: this.i18nService.t("copyPassword"), - click: () => { - this.copyValue(cipher, cipher.login.password, "password", "Password"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) - .catch(() => {}); - }, - }); - } - if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { - menu.push({ - label: this.i18nService.t("copyVerificationCodeTotp"), - click: async () => { - const value = await firstValueFrom( - this.totpService.getCode$(cipher.login.totp), - ).catch((): any => null); - if (value) { - this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); - } - }, - }); - } - break; - case CipherType.Card: - if (cipher.card.number != null || cipher.card.code != null) { - menu.push({ type: "separator" }); - } - if (cipher.card.number != null) { - menu.push({ - label: this.i18nService.t("copyNumber"), - click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), - }); - } - if (cipher.card.code != null) { - menu.push({ - label: this.i18nService.t("copySecurityCode"), - click: () => { - this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); - this.eventCollectionService - .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) - .catch(() => {}); - }, - }); - } - break; - default: - break; + if (!cipher.isDeleted) { + switch (cipher.type) { + case CipherType.Login: + if ( + cipher.login.canLaunch || + cipher.login.username != null || + cipher.login.password != null + ) { + menu.push({ type: "separator" }); + } + if (cipher.login.canLaunch) { + menu.push({ + label: this.i18nService.t("launch"), + click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + }); + } + if (cipher.login.username != null) { + menu.push({ + label: this.i18nService.t("copyUsername"), + click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + }); + } + if (cipher.login.password != null && cipher.viewPassword) { + menu.push({ + label: this.i18nService.t("copyPassword"), + click: () => { + this.copyValue(cipher, cipher.login.password, "password", "Password"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + .catch(() => {}); + }, + }); + } + if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + menu.push({ + label: this.i18nService.t("copyVerificationCodeTotp"), + click: async () => { + const value = await firstValueFrom( + this.totpService.getCode$(cipher.login.totp), + ).catch((): any => null); + if (value) { + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + } + }, + }); + } + break; + case CipherType.Card: + if (cipher.card.number != null || cipher.card.code != null) { + menu.push({ type: "separator" }); + } + if (cipher.card.number != null) { + menu.push({ + label: this.i18nService.t("copyNumber"), + click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + }); + } + if (cipher.card.code != null) { + menu.push({ + label: this.i18nService.t("copySecurityCode"), + click: () => { + this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + .catch(() => {}); + }, + }); + } + break; + default: + break; + } } + invokeMenu(menu); } From 8bd8a12f655432939989013cbdb28545905b2792 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 26 Jan 2026 16:20:38 +0100 Subject: [PATCH 38/52] Fix milestone 1 vault list not showing when not using sdk crypto (#18550) --- apps/desktop/src/vault/app/vault-v3/vault.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index 455f9177c4d..9d5fad2fe4c 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -813,6 +813,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { }; return filterFn(proxyCipher as any); } + return filterFn(cipher); }; } From 2aea6406a57de9b4e6a6d2bd80e75d04bb12389a Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 26 Jan 2026 09:24:20 -0600 Subject: [PATCH 39/52] [PM-29501] Use bit-chip-select when there are too many orgs (#18368) --- .../reports/pages/cipher-report.component.ts | 29 ++++++++++++++ .../exposed-passwords-report.component.html | 39 ++++++++++++------- .../inactive-two-factor-report.component.html | 39 ++++++++++++------- .../exposed-passwords-report.component.ts | 4 +- .../inactive-two-factor-report.component.ts | 4 +- .../reused-passwords-report.component.ts | 4 +- .../unsecured-websites-report.component.ts | 4 +- .../weak-passwords-report.component.ts | 4 +- .../reports/pages/reports-home.component.ts | 5 +-- .../reused-passwords-report.component.html | 37 +++++++++++------- .../unsecured-websites-report.component.html | 38 +++++++++++------- .../weak-passwords-report.component.html | 39 ++++++++++++------- .../src/app/dirt/reports/reports.module.ts | 2 + 13 files changed, 170 insertions(+), 78 deletions(-) diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index d098be56663..d8519b86094 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -46,8 +46,11 @@ export abstract class CipherReportComponent implements OnDestroy { organizations: Organization[] = []; organizations$: Observable; + readonly maxItemsToSwitchToChipSelect = 5; filterStatus: any = [0]; showFilterToggle: boolean = false; + selectedFilterChip: string = "0"; + chipSelectOptions: { label: string; value: string }[] = []; vaultMsg: string = "vault"; currentFilterStatus: number | string = 0; protected filterOrgStatus$ = new BehaviorSubject(0); @@ -288,6 +291,15 @@ export abstract class CipherReportComponent implements OnDestroy { return await this.cipherService.getAllDecrypted(activeUserId); } + protected canDisplayToggleGroup(): boolean { + return this.filterStatus.length <= this.maxItemsToSwitchToChipSelect; + } + + async filterOrgToggleChipSelect(filterId: string | null) { + const selectedFilterId = filterId ?? 0; + await this.filterOrgToggle(selectedFilterId); + } + protected filterCiphersByOrg(ciphersList: CipherView[]) { this.allCiphers = [...ciphersList]; @@ -309,5 +321,22 @@ export abstract class CipherReportComponent implements OnDestroy { this.showFilterToggle = false; this.vaultMsg = "vault"; } + + this.chipSelectOptions = this.setupChipSelectOptions(this.filterStatus); + } + + private setupChipSelectOptions(filters: string[]) { + const options = filters.map((filterId: string, index: number) => { + const name = this.getName(filterId); + const count = this.getCount(filterId); + const labelSuffix = count != null ? ` (${count})` : ""; + + return { + label: name + labelSuffix, + value: filterId, + }; + }); + + return options; } } diff --git a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html index fcdb3f6ca64..55e6678bd58 100644 --- a/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/exposed-passwords-report.component.html @@ -13,19 +13,32 @@ {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html index 9a99a55b77b..a1d3f2a38be 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.html @@ -18,19 +18,32 @@ {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts index 1d3d8d71f5a..6c81cbd9986 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/exposed-passwords-report.component.ts @@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { PasswordRepromptService, CipherFormConfigService, @@ -45,7 +45,7 @@ import { ExposedPasswordsReportComponent as BaseExposedPasswordsReportComponent RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts index 23d1330dad7..6b93b289df9 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/inactive-two-factor-report.component.ts @@ -11,7 +11,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -39,7 +39,7 @@ import { InactiveTwoFactorReportComponent as BaseInactiveTwoFactorReportComponen RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class InactiveTwoFactorReportComponent extends BaseInactiveTwoFactorReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts index 599774d5515..0ae9ecad0cb 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/reused-passwords-report.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -44,7 +44,7 @@ import { ReusedPasswordsReportComponent as BaseReusedPasswordsReportComponent } RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class ReusedPasswordsReportComponent extends BaseReusedPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts index 6bf741b86eb..0b7cd3bfe7c 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/unsecured-websites-report.component.ts @@ -15,7 +15,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -44,7 +44,7 @@ import { UnsecuredWebsitesReportComponent as BaseUnsecuredWebsitesReportComponen RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts index 6780b65931c..411295ceb2a 100644 --- a/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/organizations/weak-passwords-report.component.ts @@ -16,7 +16,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { DialogService } from "@bitwarden/components"; +import { ChipSelectComponent, DialogService } from "@bitwarden/components"; import { CipherFormConfigService, PasswordRepromptService, @@ -45,7 +45,7 @@ import { WeakPasswordsReportComponent as BaseWeakPasswordsReportComponent } from RoutedVaultFilterService, RoutedVaultFilterBridgeService, ], - imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule], + imports: [SharedModule, HeaderModule, OrganizationBadgeModule, PipesModule, ChipSelectComponent], }) export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportComponent diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts index a0e3a73aa3f..25cf663ba7e 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.ts +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -9,9 +9,8 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { reports, ReportType } from "../reports"; import { ReportEntry, ReportVariant } from "../shared"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "app-reports-home", templateUrl: "reports-home.component.html", standalone: false, diff --git a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html index d09dfa81fd4..62496dfad00 100644 --- a/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/reused-passwords-report.component.html @@ -19,19 +19,30 @@ {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } diff --git a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html index cc7537333ad..276508b3801 100644 --- a/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/unsecured-websites-report.component.html @@ -19,19 +19,31 @@ {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html index 92d56c1c7a3..96bae4c3e0a 100644 --- a/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/weak-passwords-report.component.html @@ -18,19 +18,32 @@ {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - - {{ getName(status) }} - {{ getCount(status) }} - - - + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + + + {{ getName(status) }} + {{ getCount(status) }} + + + + } @else { + + } + } + diff --git a/apps/web/src/app/dirt/reports/reports.module.ts b/apps/web/src/app/dirt/reports/reports.module.ts index 5648b40982a..4fc152917f4 100644 --- a/apps/web/src/app/dirt/reports/reports.module.ts +++ b/apps/web/src/app/dirt/reports/reports.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { ChipSelectComponent } from "@bitwarden/components"; import { CipherFormConfigService, DefaultCipherFormConfigService, @@ -34,6 +35,7 @@ import { ReportsSharedModule } from "./shared"; OrganizationBadgeModule, PipesModule, HeaderModule, + ChipSelectComponent, ], declarations: [ BreachReportComponent, From c2b55e31cfaa8bfc057ac4f84107ef6bef932531 Mon Sep 17 00:00:00 2001 From: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:06:39 +0000 Subject: [PATCH 40/52] Bumped client version(s) --- apps/browser/package.json | 2 +- apps/browser/src/manifest.json | 2 +- apps/browser/src/manifest.v3.json | 2 +- apps/cli/package.json | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 8 ++++---- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/browser/package.json b/apps/browser/package.json index 7055aabf4fd..745c9d6f3e3 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.12.1", + "version": "2026.1.0", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 26add57d1ae..ce5311f848a 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.12.1", + "version": "2026.1.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index 64d182ebd3d..9cb77aa3040 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.12.1", + "version": "2026.1.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/cli/package.json b/apps/cli/package.json index 5174e324586..a19c811b4bf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.1", + "version": "2026.1.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 174f3a22a23..aabf26e76bd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.1", + "version": "2026.1.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 9d8eae15791..08cbdb913e6 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 2ac5d339a95..859a18fefd0 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.1", + "version": "2026.1.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/package.json b/apps/web/package.json index 0e844fbbe79..033c5b000bf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2026.1.0", + "version": "2026.1.1", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/package-lock.json b/package-lock.json index ff632dc2807..42206a1b46c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,11 +192,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2025.12.1" + "version": "2026.1.0" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2025.12.1", + "version": "2026.1.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "4.0.0", @@ -278,7 +278,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.12.1", + "version": "2026.1.0", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -491,7 +491,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2026.1.0" + "version": "2026.1.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", From 178fd9a5776f120516c9bf51d7234b9cf8bc4381 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:16:40 -0600 Subject: [PATCH 41/52] [PM-30808] Migrate Phishing Detection storage to PhishingIndexedDbService (#18517) * Initial changes to look at phishing indexeddb service and removal of obsolete compression code * Convert background update to rxjs format and trigger via subject. Update test cases * Added addUrls function to use instead of saveUrls so appending daily does not clear all urls * Added debug logs to phishing-indexeddb service * Added a fallback url when downloading phishing url list * Remove obsolete comments * Fix testUrl default, false scenario and test cases * Add default return on isPhishingWebAddress * Added log statement * Change hostname to href in hasUrl check * Save fallback response * Fix matching subpaths in links. Update test cases * Fix meta data updates storing last checked instead of last updated * Update QA phishing url to be normalized * Filter web addresses * Return previous meta to keep subscription alive --- .../phishing-detection/phishing-resources.ts | 12 +- .../services/phishing-data.service.spec.ts | 512 ++++++++++-------- .../services/phishing-data.service.ts | 488 ++++++++--------- .../phishing-indexeddb.service.spec.ts | 80 +++ .../services/phishing-indexeddb.service.ts | 35 ++ 5 files changed, 638 insertions(+), 489 deletions(-) diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 4cd155c8ae3..88068987dd7 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -1,6 +1,8 @@ export type PhishingResource = { name?: string; remoteUrl: string; + /** Fallback URL to use if remoteUrl fails (e.g., due to SSL interception/cert issues) */ + fallbackUrl: string; checksumUrl: string; todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ @@ -19,6 +21,8 @@ export const PHISHING_RESOURCES: Record - new Promise((resolve) => jest.requireActual("timers").setImmediate(resolve)); - -// [FIXME] Move mocking and compression helpers to a shared test utils library -// to separate from phishing data service tests. -export const setupPhishingMocks = (mockedResult: string | ArrayBuffer = "mocked-data") => { - // Store original globals - const originals = { - Response: global.Response, - CompressionStream: global.CompressionStream, - DecompressionStream: global.DecompressionStream, - Blob: global.Blob, - atob: global.atob, - btoa: global.btoa, - }; - - // Mock missing or browser-only globals - global.atob = (str) => Buffer.from(str, "base64").toString("binary"); - global.btoa = (str) => Buffer.from(str, "binary").toString("base64"); - - (global as any).CompressionStream = class {}; - (global as any).DecompressionStream = class {}; - - global.Blob = class { - constructor(public parts: any[]) {} - stream() { - return { pipeThrough: () => ({}) }; - } - } as any; - - global.Response = class { - body = { pipeThrough: () => ({}) }; - // Return string for decompression - text() { - return Promise.resolve(typeof mockedResult === "string" ? mockedResult : ""); - } - // Return ArrayBuffer for compression - arrayBuffer() { - if (typeof mockedResult === "string") { - const bytes = new TextEncoder().encode(mockedResult); - return Promise.resolve(bytes.buffer); - } - - return Promise.resolve(mockedResult); - } - } as any; - - // Cleanup function - return () => { - Object.assign(global, originals); - }; -}; +import { PHISHING_DOMAINS_META_KEY, PhishingDataService } from "./phishing-data.service"; +import type { PhishingIndexedDbService } from "./phishing-indexeddb.service"; describe("PhishingDataService", () => { let service: PhishingDataService; @@ -76,33 +19,30 @@ describe("PhishingDataService", () => { let taskSchedulerService: TaskSchedulerService; let logService: MockProxy; let platformUtilsService: MockProxy; + let mockIndexedDbService: MockProxy; const fakeGlobalStateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); - - const setMockMeta = (state: PhishingDataMeta) => { - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_META_KEY).stateSubject.next(state); - return state; - }; - const setMockBlob = (state: PhishingDataBlob) => { - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(state); - return state; - }; - let fetchChecksumSpy: jest.SpyInstance; - let fetchAndCompressSpy: jest.SpyInstance; - - const mockMeta: PhishingDataMeta = { - checksum: "abc", - timestamp: Date.now(), - applicationVersion: "1.0.0", - }; - const mockBlob = "http://phish.com\nhttps://badguy.net"; - const mockCompressedBlob = - "H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA="; beforeEach(async () => { - jest.useFakeTimers(); + jest.clearAllMocks(); + + // Mock Request global if not available + if (typeof Request === "undefined") { + (global as any).Request = class { + constructor(public url: string) {} + }; + } + apiService = mock(); logService = mock(); + mockIndexedDbService = mock(); + + // Set default mock behaviors + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.saveUrls.mockResolvedValue(undefined); + mockIndexedDbService.addUrls.mockResolvedValue(undefined); + mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined); platformUtilsService = mock(); platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); @@ -116,217 +56,315 @@ describe("PhishingDataService", () => { logService, platformUtilsService, ); - fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); - fetchAndCompressSpy = jest.spyOn(service as any, "fetchAndCompress"); + // Replace the IndexedDB service with our mock + service["indexedDbService"] = mockIndexedDbService; + + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingChecksum"); fetchChecksumSpy.mockResolvedValue("new-checksum"); - fetchAndCompressSpy.mockResolvedValue("compressed-blob"); }); describe("initialization", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + it("should initialize with IndexedDB service", () => { + expect(service["indexedDbService"]).toBeDefined(); }); - it("should perform background update", async () => { - platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.x"); - jest - .spyOn(service as any, "getNextWebAddresses") - .mockResolvedValue({ meta: mockMeta, blob: mockBlob }); - - setMockBlob(mockBlob); - setMockMeta(mockMeta); - - const sub = service.update$.subscribe(); - await flushPromises(); - - const url = new URL("http://phish.com"); - const QAurl = new URL("http://phishing.testcategory.com"); + it("should detect QA test addresses - http protocol", async () => { + const url = new URL("http://phishing.testcategory.com"); expect(await service.isPhishingWebAddress(url)).toBe(true); - expect(await service.isPhishingWebAddress(QAurl)).toBe(true); + // IndexedDB should not be called for test addresses + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); - sub.unsubscribe(); + it("should detect QA test addresses - https protocol", async () => { + const url = new URL("https://phishing.testcategory.com"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + it("should detect QA test addresses - specific subpath /block", async () => { + const url = new URL("https://phishing.testcategory.com/block"); + expect(await service.isPhishingWebAddress(url)).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); + }); + + it("should NOT detect QA test addresses - different subpath", async () => { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + + const url = new URL("https://phishing.testcategory.com/other"); + const result = await service.isPhishingWebAddress(url); + + // This should NOT be detected as a test address since only /block subpath is hardcoded + expect(result).toBe(false); + }); + + it("should detect QA test addresses - root path with trailing slash", async () => { + const url = new URL("https://phishing.testcategory.com/"); + const result = await service.isPhishingWebAddress(url); + + // This SHOULD be detected since URLs are normalized (trailing slash added to root URLs) + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).not.toHaveBeenCalled(); }); }); describe("isPhishingWebAddress", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); - }); + it("should detect a phishing web address using quick hasUrl lookup", async () => { + // Mock hasUrl to return true for direct hostname match + mockIndexedDbService.hasUrl.mockResolvedValue(true); - it("should detect a phishing web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); - - const url = new URL("http://phish.com"); + const url = new URL("http://phish.com/testing-param"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param"); + // Should not fall back to custom matcher when hasUrl returns true + expect(mockIndexedDbService.loadAllUrls).not.toHaveBeenCalled(); + }); + + it("should fall back to custom matcher when hasUrl returns false", async () => { + // Mock hasUrl to return false (no direct href match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock loadAllUrls to return phishing URLs for custom matcher + mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/path"]); + + const url = new URL("http://phish.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path"); + expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); }); it("should not detect a safe web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); + // Mock hasUrl to return false + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock loadAllUrls to return phishing URLs that don't match + mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com", "http://badguy.net"]); + const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/"); + expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); }); - it("should match against root web address", async () => { - service["_webAddressesSet"] = new Set(["phish.com", "badguy.net"]); - const url = new URL("http://phish.com/about"); + it("should not match against root web address with subpaths using custom matcher", async () => { + // Mock hasUrl to return false (no direct href match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock loadAllUrls to return entry that matches with subpath + mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login"]); + + const url = new URL("http://phish.com/login/page"); const result = await service.isPhishingWebAddress(url); - expect(result).toBe(true); + + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page"); + expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); }); - it("should not error on empty state", async () => { - service["_webAddressesSet"] = null; + it("should not match against root web address with different subpaths using custom matcher", async () => { + // Mock hasUrl to return false (no direct hostname match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock loadAllUrls to return entry that matches with subpath + mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login/page1"]); + + const url = new URL("http://phish.com/login/page2"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2"); + expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + }); + + it("should handle IndexedDB errors gracefully", async () => { + // Mock hasUrl to throw error + mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error")); + // Mock loadAllUrls to also throw error + mockIndexedDbService.loadAllUrls.mockRejectedValue(new Error("IndexedDB error")); + const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] IndexedDB lookup via hasUrl failed", + expect.any(Error), + ); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] Error running custom matcher", + expect.any(Error), + ); }); }); - describe("getNextWebAddresses", () => { - beforeEach(() => { - jest.spyOn(service as any, "_compressString").mockResolvedValue(mockCompressedBlob); - jest.spyOn(service as any, "_decompressString").mockResolvedValue(mockBlob); + describe("data updates", () => { + it("should update full dataset via stream", async () => { + // Mock full dataset update + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + await firstValueFrom(service["_updateFullDataSet"]()); + + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); }); - it("refetches all web addresses if applicationVersion has changed", async () => { - const prev: PhishingDataMeta = { - timestamp: Date.now() - 60000, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); + it("should update daily dataset via addUrls", async () => { + // Mock daily update + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue("newphish.com\nanotherbad.net"), + } as unknown as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + await firstValueFrom(service["_updateDailyDataSet"]()); + + expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newphish.com", "anotherbad.net"]); + }); + + it("should get updated meta information", async () => { + fetchChecksumSpy.mockResolvedValue("new-checksum"); platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); - const result = await service.getNextWebAddresses(prev); + const meta = await firstValueFrom(service["_getUpdatedMeta"]()); - expect(result!.blob).toBe("compressed-blob"); - expect(result!.meta!.checksum).toBe("new"); - expect(result!.meta!.applicationVersion).toBe("2.0.0"); - }); - - it("returns null when checksum matches and cache not expired", async () => { - const prev: PhishingDataMeta = { - timestamp: Date.now(), - checksum: "abc", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("abc"); - const result = await service.getNextWebAddresses(prev); - expect(result).toBeNull(); - }); - - it("patches daily domains when cache is expired and checksum unchanged", async () => { - const prev: PhishingDataMeta = { - timestamp: 0, - checksum: "old", - applicationVersion: "1.0.0", - }; - const dailyLines = ["b.com", "c.com"]; - fetchChecksumSpy.mockResolvedValue("old"); - jest.spyOn(service as any, "fetchText").mockResolvedValue(dailyLines); - - setMockBlob(mockBlob); - - const expectedBlob = - "H4sIAAAAAAAA/8vMTSzJzM9TSE7MLchJLElVyE9TyC9KSS1S0FFIz8hLz0ksSQUAtK7XMSYAAAA="; - const result = await service.getNextWebAddresses(prev); - - expect(result!.blob).toBe(expectedBlob); - expect(result!.meta!.checksum).toBe("old"); - }); - - it("fetches all domains when checksum has changed", async () => { - const prev: PhishingDataMeta = { - timestamp: 0, - checksum: "old", - applicationVersion: "1.0.0", - }; - fetchChecksumSpy.mockResolvedValue("new"); - fetchAndCompressSpy.mockResolvedValue("new-blob"); - const result = await service.getNextWebAddresses(prev); - expect(result!.blob).toBe("new-blob"); - expect(result!.meta!.checksum).toBe("new"); + expect(meta).toBeDefined(); + expect(meta.checksum).toBe("new-checksum"); + expect(meta.applicationVersion).toBe("2.0.0"); + expect(meta.timestamp).toBeDefined(); }); }); - describe("compression helpers", () => { - let restore: () => void; + describe("phishing meta data updates", () => { + it("should not update metadata when no data updates occur", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "existing-checksum", + timestamp: Date.now() - 1000, // 1 second ago (not expired) + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - beforeEach(async () => { - restore = setupPhishingMocks("abc"); + // Mock conditions where no update is needed + fetchChecksumSpy.mockResolvedValue("existing-checksum"); // Same checksum + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata was NOT updated (same reference returned) + expect(result).toEqual(existingMeta); + expect(result?.timestamp).toBe(existingMeta.timestamp); + + // Verify no data updates were performed + expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); }); - afterEach(() => { - if (restore) { - restore(); - } - delete (Uint8Array as any).fromBase64; - jest.restoreAllMocks(); + it("should update metadata when full dataset update occurs due to checksum change", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "old-checksum", + timestamp: Date.now() - 1000, + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); + + // Mock conditions for full update + fetchChecksumSpy.mockResolvedValue("new-checksum"); // Different checksum + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); + + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated with new values + expect(result?.checksum).toBe("new-checksum"); + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + + // Verify full update was performed + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); // Daily should not run }); - describe("_compressString", () => { - it("compresses a string to base64", async () => { - const out = await service["_compressString"]("abc"); - expect(out).toBe("YWJj"); // base64 for 'abc' - }); + it("should update metadata when full dataset update occurs due to version change", async () => { + // Set up existing metadata + const existingMeta = { + checksum: "same-checksum", + timestamp: Date.now() - 1000, + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - it("compresses using fallback on older browsers", async () => { - const input = "abc"; - const expected = btoa(encodeURIComponent(input)); - const out = await service["_compressString"](input); - expect(out).toBe(expected); - }); + // Mock conditions for full update + fetchChecksumSpy.mockResolvedValue("same-checksum"); + platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); // Different version + const mockResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + apiService.nativeFetch.mockResolvedValue(mockResponse); - it("compresses using btoa on error", async () => { - const input = "abc"; - const expected = btoa(encodeURIComponent(input)); - const out = await service["_compressString"](input); - expect(out).toBe(expected); - }); + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); + + // Verify metadata WAS updated + expect(result?.applicationVersion).toBe("2.0.0"); + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + + // Verify full update was performed + expect(mockIndexedDbService.saveUrlsFromStream).toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).not.toHaveBeenCalled(); }); - describe("_decompressString", () => { - it("decompresses a string from base64", async () => { - const base64 = btoa("ignored"); - const out = await service["_decompressString"](base64); - expect(out).toBe("abc"); - }); - it("decompresses using fallback on older browsers", async () => { - // Provide a fromBase64 implementation - (Uint8Array as any).fromBase64 = (b64: string) => new Uint8Array([100, 101, 102]); + it("should update metadata when daily update occurs due to cache expiration", async () => { + // Set up existing metadata (expired cache) + const existingMeta = { + checksum: "same-checksum", + timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago (expired) + applicationVersion: "1.0.0", + }; + await fakeGlobalStateProvider.get(PHISHING_DOMAINS_META_KEY).update(() => existingMeta); - const out = await service["_decompressString"]("ignored"); - expect(out).toBe("abc"); - }); + // Mock conditions for daily update only + fetchChecksumSpy.mockResolvedValue("same-checksum"); // Same checksum (no full update) + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); // Same version + const mockFullResponse = { + ok: true, + body: {} as ReadableStream, + } as Response; + const mockDailyResponse = { + ok: true, + text: jest.fn().mockResolvedValue("newdomain.com"), + } as unknown as Response; + apiService.nativeFetch + .mockResolvedValueOnce(mockFullResponse) + .mockResolvedValueOnce(mockDailyResponse); - it("decompresses using atob on error", async () => { - const base64 = btoa(encodeURIComponent("abc")); - const out = await service["_decompressString"](base64); - expect(out).toBe("abc"); - }); - }); - }); + // Trigger background update + const result = await firstValueFrom(service["_backgroundUpdate"](existingMeta)); - describe("_loadBlobToMemory", () => { - it("loads blob into memory set", async () => { - const prevBlob = "ignored-base64"; - fakeGlobalStateProvider.getFake(PHISHING_DOMAINS_BLOB_KEY).stateSubject.next(prevBlob); + // Verify metadata WAS updated + expect(result?.timestamp).toBeGreaterThan(existingMeta.timestamp); + expect(result?.checksum).toBe("same-checksum"); - jest.spyOn(service as any, "_decompressString").mockResolvedValue("phish.com\nbadguy.net"); - - // Trigger the load pipeline and allow async RxJS processing to complete - service["_loadBlobToMemory"](); - await flushPromises(); - - const set = service["_webAddressesSet"] as Set; - expect(set).toBeDefined(); - expect(set.has("phish.com")).toBe(true); - expect(set.has("badguy.net")).toBe(true); + // Verify only daily update was performed + expect(mockIndexedDbService.saveUrlsFromStream).not.toHaveBeenCalled(); + expect(mockIndexedDbService.addUrls).toHaveBeenCalledWith(["newdomain.com"]); }); }); }); 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 7d5f04cc276..10268fa7f93 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 @@ -1,17 +1,25 @@ import { catchError, + concatMap, + defer, EMPTY, + exhaustMap, first, - firstValueFrom, + forkJoin, from, + iif, + map, + Observable, of, + retry, share, takeUntil, startWith, Subject, switchMap, tap, - map, + throwError, + timer, } from "rxjs"; import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; @@ -23,6 +31,8 @@ import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bi import { getPhishingResources, PhishingResourceType } from "../phishing-resources"; +import { PhishingIndexedDbService } from "./phishing-indexeddb.service"; + /** * Metadata about the phishing data set */ @@ -73,19 +83,16 @@ export class PhishingDataService { // We are adding the destroy to guard against accidental leaks. private _destroy$ = new Subject(); - private _testWebAddresses = this.getTestWebAddresses().concat("phishing.testcategory.com"); // Included for QA to test in prod + private _testWebAddresses = this.getTestWebAddresses(); private _phishingMetaState = this.globalStateProvider.get(PHISHING_DOMAINS_META_KEY); - private _phishingBlobState = this.globalStateProvider.get(PHISHING_DOMAINS_BLOB_KEY); - // In-memory set loaded from blob for fast lookups without reading large storage repeatedly - private _webAddressesSet: Set | null = null; - // Loading variables for web addresses set - // Triggers a load for _webAddressesSet - private _loadTrigger$ = new Subject(); + private indexedDbService: PhishingIndexedDbService; // How often are new web addresses added to the remote? readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + private _backgroundUpdateTrigger$ = new Subject(); + private _triggerUpdate$ = new Subject(); update$ = this._triggerUpdate$.pipe( startWith(undefined), // Always emit once @@ -93,12 +100,8 @@ export class PhishingDataService { this._phishingMetaState.state$.pipe( first(), // Only take the first value to avoid an infinite loop when updating the cache below tap((metaState) => { - // Initial loading of web addresses set if not already loaded - if (!this._webAddressesSet) { - this._loadBlobToMemory(); - } - // Perform any updates in the background if needed - void this._backgroundUpdate(metaState); + // Perform any updates in the background + this._backgroundUpdateTrigger$.next(metaState); }), catchError((err: unknown) => { this.logService.error("[PhishingDataService] Background update failed to start.", err); @@ -106,7 +109,6 @@ export class PhishingDataService { }), ), ), - // Stop emitting when dispose() is called takeUntil(this._destroy$), share(), ); @@ -120,6 +122,7 @@ export class PhishingDataService { private resourceType: PhishingResourceType = PhishingResourceType.Links, ) { this.logService.debug("[PhishingDataService] Initializing service..."); + this.indexedDbService = new PhishingIndexedDbService(this.logService); this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { this._triggerUpdate$.next(); }); @@ -127,18 +130,20 @@ export class PhishingDataService { ScheduledTaskNames.phishingDomainUpdate, this.UPDATE_INTERVAL_DURATION, ); - this._setupLoadPipeline(); + this._backgroundUpdateTrigger$ + .pipe( + exhaustMap((currentMeta) => { + return this._backgroundUpdate(currentMeta); + }), + takeUntil(this._destroy$), + ) + .subscribe(); } dispose(): void { // Signal all pipelines to stop and unsubscribe stored subscriptions this._destroy$.next(); this._destroy$.complete(); - - // Clear web addresses set from memory - if (this._webAddressesSet !== null) { - this._webAddressesSet = null; - } } /** @@ -148,105 +153,65 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { - if (!this._webAddressesSet) { - this.logService.debug("[PhishingDataService] Set not loaded; skipping check"); - return false; + // Quick check for QA/dev test addresses + if (this._testWebAddresses.includes(url.href)) { + return true; } - const set = this._webAddressesSet!; const resource = getPhishingResources(this.resourceType); - // Custom matcher per resource - if (resource && resource?.match) { - for (const entry of set) { - if (resource.match(url, entry)) { - return true; + try { + // Quick lookup: check direct presence of href in IndexedDB + const hasUrl = await this.indexedDbService.hasUrl(url.href); + if (hasUrl) { + return true; + } + } catch (err) { + this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err); + } + + // If a custom matcher is provided, iterate stored entries and apply the matcher. + if (resource && resource.match) { + try { + const entries = await this.indexedDbService.loadAllUrls(); + for (const entry of entries) { + if (resource.match(url, entry)) { + return true; + } } + } catch (err) { + this.logService.error("[PhishingDataService] Error running custom matcher", err); } return false; } - - // Default set-based lookup - return set.has(url.hostname); - } - - async getNextWebAddresses( - previous: PhishingDataMeta | null, - ): Promise | null> { - const prevMeta = previous ?? { timestamp: 0, checksum: "", applicationVersion: "" }; - const now = Date.now(); - - // Updates to check - const applicationVersion = await this.platformUtilsService.getApplicationVersion(); - const remoteChecksum = await this.fetchPhishingChecksum(this.resourceType); - - // Logic checks - const appVersionChanged = applicationVersion !== prevMeta.applicationVersion; - const masterChecksumChanged = remoteChecksum !== prevMeta.checksum; - - // Check for full updated - if (masterChecksumChanged || appVersionChanged) { - this.logService.info("[PhishingDataService] Checksum or version changed; Fetching ALL."); - const remoteUrl = getPhishingResources(this.resourceType)!.remoteUrl; - const blob = await this.fetchAndCompress(remoteUrl); - return { - blob, - meta: { checksum: remoteChecksum, timestamp: now, applicationVersion }, - }; - } - - // Check for daily file - const isCacheExpired = now - prevMeta.timestamp > this.UPDATE_INTERVAL_DURATION; - - if (isCacheExpired) { - this.logService.info("[PhishingDataService] Daily cache expired; Fetching TODAY's"); - const url = getPhishingResources(this.resourceType)!.todayUrl; - const newLines = await this.fetchText(url); - const prevBlob = (await firstValueFrom(this._phishingBlobState.state$)) ?? ""; - const oldText = prevBlob ? await this._decompressString(prevBlob) : ""; - - // Join the new lines to the existing list - const combined = (oldText ? oldText + "\n" : "") + newLines.join("\n"); - - return { - blob: await this._compressString(combined), - meta: { - checksum: remoteChecksum, - timestamp: now, // Reset the timestamp - applicationVersion, - }, - }; - } - - return null; + return false; } + // [FIXME] Pull fetches into api service private async fetchPhishingChecksum(type: PhishingResourceType = PhishingResourceType.Domains) { const checksumUrl = getPhishingResources(type)!.checksumUrl; - const response = await this.apiService.nativeFetch(new Request(checksumUrl)); - if (!response.ok) { - throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); - } - return response.text(); - } - private async fetchAndCompress(url: string): Promise { - const response = await this.apiService.nativeFetch(new Request(url)); - if (!response.ok) { - throw new Error("Fetch failed"); - } + this.logService.debug(`[PhishingDataService] Fetching checksum from: ${checksumUrl}`); - const downloadStream = response.body!; - // Pipe through CompressionStream while it's downloading - const compressedStream = downloadStream.pipeThrough(new CompressionStream("gzip")); - // Convert to ArrayBuffer - const buffer = await new Response(compressedStream).arrayBuffer(); - const bytes = new Uint8Array(buffer); + try { + const response = await this.apiService.nativeFetch(new Request(checksumUrl)); + if (!response.ok) { + throw new Error( + `[PhishingDataService] Failed to fetch checksum: ${response.status} ${response.statusText}`, + ); + } - // Return as Base64 for storage - return (bytes as any).toBase64 ? (bytes as any).toBase64() : this._uint8ToBase64Fallback(bytes); + return await response.text(); + } catch (error) { + this.logService.error( + `[PhishingDataService] Checksum fetch failed from ${checksumUrl}`, + error, + ); + throw error; + } } - private async fetchText(url: string) { + // [FIXME] Pull fetches into api service + private async fetchToday(url: string) { const response = await this.apiService.nativeFetch(new Request(url)); if (!response.ok) { @@ -258,171 +223,196 @@ export class PhishingDataService { private getTestWebAddresses() { const flag = devFlagEnabled("testPhishingUrls"); + // Normalize URLs by converting to URL object and back to ensure consistent format (e.g., trailing slashes) + const testWebAddresses: string[] = [ + new URL("http://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com").href, + new URL("https://phishing.testcategory.com/block").href, + ]; if (!flag) { - return []; + return testWebAddresses; } const webAddresses = devFlagValue("testPhishingUrls") as unknown[]; if (webAddresses && webAddresses instanceof Array) { this.logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", + "[PhishingDataService] Dev flag enabled for testing phishing detection. Adding test phishing web addresses:", webAddresses, ); - return webAddresses as string[]; + // Normalize dev flag URLs as well, filtering out invalid ones + const normalizedDevAddresses = (webAddresses as string[]) + .filter((addr) => { + try { + new URL(addr); + return true; + } catch { + this.logService.warning( + `[PhishingDataService] Invalid test URL in dev flag, skipping: ${addr}`, + ); + return false; + } + }) + .map((addr) => new URL(addr).href); + return testWebAddresses.concat(normalizedDevAddresses); } - return []; + return testWebAddresses; } - // Runs the update flow in the background and retries up to 3 times on failure - private async _backgroundUpdate(previous: PhishingDataMeta | null): Promise { - this.logService.info(`[PhishingDataService] Update web addresses triggered...`); - const phishingMeta: PhishingDataMeta = previous ?? { - timestamp: 0, - checksum: "", - applicationVersion: "", - }; - // Start time for logging performance of update - const startTime = Date.now(); - const maxAttempts = 3; - const delayMs = 5 * 60 * 1000; // 5 minutes + private _getUpdatedMeta(): Observable { + return defer(() => { + const now = Date.now(); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const next = await this.getNextWebAddresses(phishingMeta); - if (!next) { - return; // No update needed - } + return forkJoin({ + applicationVersion: from(this.platformUtilsService.getApplicationVersion()), + remoteChecksum: from(this.fetchPhishingChecksum(this.resourceType)), + }).pipe( + map(({ applicationVersion, remoteChecksum }) => { + return { + checksum: remoteChecksum, + timestamp: now, + applicationVersion, + }; + }), + ); + }); + } - if (next.meta) { - await this._phishingMetaState.update(() => next!.meta!); - } - if (next.blob) { - await this._phishingBlobState.update(() => next!.blob!); - this._loadBlobToMemory(); - } + // Streams the full phishing data set and saves it to IndexedDB + private _updateFullDataSet() { + const resource = getPhishingResources(this.resourceType); - // Performance logging - const elapsed = Date.now() - startTime; - this.logService.info(`[PhishingDataService] Phishing data cache updated in ${elapsed}ms`); - } catch (err) { - this.logService.error( - `[PhishingDataService] Unable to update web addresses. Attempt ${attempt}.`, - err, - ); - if (attempt < maxAttempts) { - await new Promise((res) => setTimeout(res, delayMs)); - } else { - const elapsed = Date.now() - startTime; - this.logService.error( - `[PhishingDataService] Retries unsuccessful after ${elapsed}ms. Unable to update web addresses.`, - err, + if (!resource?.remoteUrl) { + return throwError(() => new Error("Invalid resource URL")); + } + + this.logService.info(`[PhishingDataService] Starting FULL update using ${resource.remoteUrl}`); + return from(this.apiService.nativeFetch(new Request(resource.remoteUrl))).pipe( + switchMap((response) => { + if (!response.ok || !response.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Full fetch failed: ${response.status}, ${response.statusText}`, + ), ); } - } - } + + return from(this.indexedDbService.saveUrlsFromStream(response.body)); + }), + catchError((err: unknown) => { + this.logService.error( + `[PhishingDataService] Full dataset update failed using primary source ${err}`, + ); + this.logService.warning( + `[PhishingDataService] Falling back to: ${resource.fallbackUrl} (Note: Fallback data may be less up-to-date)`, + ); + // Try fallback URL + return from(this.apiService.nativeFetch(new Request(resource.fallbackUrl))).pipe( + switchMap((fallbackResponse) => { + if (!fallbackResponse.ok || !fallbackResponse.body) { + return throwError( + () => + new Error( + `[PhishingDataService] Fallback fetch failed: ${fallbackResponse.status}, ${fallbackResponse.statusText}`, + ), + ); + } + + return from(this.indexedDbService.saveUrlsFromStream(fallbackResponse.body)); + }), + catchError((fallbackError: unknown) => { + this.logService.error(`[PhishingDataService] Fallback source failed`); + return throwError(() => fallbackError); + }), + ); + }), + ); } - // Sets up the load pipeline to load the blob into memory when triggered - private _setupLoadPipeline(): void { - this._loadTrigger$ - .pipe( - switchMap(() => - this._phishingBlobState.state$.pipe( - first(), - switchMap((blobBase64) => { - if (!blobBase64) { - return of(undefined); - } - // Note: _decompressString wraps a promise that cannot be aborted - // If performance improvements are needed, consider migrating to a cancellable approach - return from(this._decompressString(blobBase64)).pipe( - map((text) => { - const lines = text.split(/\r?\n/); - const newWebAddressesSet = new Set(lines); - this._testWebAddresses.forEach((a) => newWebAddressesSet.add(a)); - this._webAddressesSet = new Set(newWebAddressesSet); - this.logService.info( - `[PhishingDataService] loaded ${this._webAddressesSet.size} addresses into memory from blob`, - ); - }), + private _updateDailyDataSet() { + this.logService.info("[PhishingDataService] Starting DAILY update..."); + + const todayUrl = getPhishingResources(this.resourceType)?.todayUrl; + if (!todayUrl) { + return throwError(() => new Error("Today URL missing")); + } + + return from(this.fetchToday(todayUrl)).pipe( + switchMap((lines) => from(this.indexedDbService.addUrls(lines))), + ); + } + + private _backgroundUpdate( + previous: PhishingDataMeta | null, + ): Observable { + // Use defer to restart timer if retry is activated + return defer(() => { + const startTime = Date.now(); + this.logService.info(`[PhishingDataService] Update triggered...`); + + // Get updated meta info + return this._getUpdatedMeta().pipe( + // Update full data set if application version or checksum changed + concatMap((newMeta) => + iif( + () => { + const appVersionChanged = newMeta.applicationVersion !== previous?.applicationVersion; + const checksumChanged = newMeta.checksum !== previous?.checksum; + + this.logService.info( + `[PhishingDataService] Checking if full update is needed: appVersionChanged=${appVersionChanged}, checksumChanged=${checksumChanged}`, ); - }), - catchError((err: unknown) => { - this.logService.error("[PhishingDataService] Failed to load blob into memory", err); - return of(undefined); - }), + return appVersionChanged || checksumChanged; + }, + this._updateFullDataSet().pipe(map(() => ({ meta: newMeta, updated: true }))), + of({ meta: newMeta, updated: false }), ), ), - catchError((err: unknown) => { - this.logService.error("[PhishingDataService] Load pipeline failed", err); - return of(undefined); + // Update daily data set if last update was more than UPDATE_INTERVAL_DURATION ago + concatMap((result) => + iif( + () => { + const isCacheExpired = + Date.now() - (previous?.timestamp ?? 0) > this.UPDATE_INTERVAL_DURATION; + return isCacheExpired; + }, + this._updateDailyDataSet().pipe(map(() => ({ meta: result.meta, updated: true }))), + of(result), + ), + ), + concatMap((result) => { + if (!result.updated) { + this.logService.debug(`[PhishingDataService] No update needed, metadata unchanged`); + return of(previous); + } + + this.logService.debug(`[PhishingDataService] Updated phishing meta data:`, result.meta); + return from(this._phishingMetaState.update(() => result.meta)).pipe( + tap(() => { + const elapsed = Date.now() - startTime; + this.logService.info(`[PhishingDataService] Updated data set in ${elapsed}ms`); + }), + ); }), - takeUntil(this._destroy$), - share(), - ) - .subscribe(); - } - - // [FIXME] Move compression helpers to a shared utils library - // to separate from phishing data service. - // ------------------------- Blob and Compression Handling ------------------------- - private async _compressString(input: string): Promise { - 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)); - } - } - - // Trigger a load of the blob into memory - private _loadBlobToMemory(): void { - this._loadTrigger$.next(); - } - private _uint8ToBase64Fallback(bytes: Uint8Array): string { - const CHUNK_SIZE = 0x8000; // 32KB chunks - let binary = ""; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - const chunk = bytes.subarray(i, i + CHUNK_SIZE); - binary += String.fromCharCode.apply(null, chunk as any); - } - return btoa(binary); - } - - private _base64ToUint8Fallback(base64: string): Uint8Array { - const binary = atob(base64); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + retry({ + count: 2, // Total 3 attempts (initial + 2 retries) + delay: (error, retryCount) => { + this.logService.error( + `[PhishingDataService] Attempt ${retryCount} failed. Retrying in 5m...`, + error, + ); + return timer(5 * 60 * 1000); // Wait 5 mins before next attempt + }, + }), + catchError((err: unknown) => { + const elapsed = Date.now() - startTime; + this.logService.error( + `[PhishingDataService] Retries unsuccessful after ${elapsed}ms.`, + err, + ); + return of(previous); + }), + ); + }); } } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts index 75bd634b1fc..99e101cc199 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -215,6 +215,86 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("addUrls", () => { + it("appends URLs to IndexedDB without clearing", async () => { + // Pre-populate store with existing data + mockStore.set("https://existing.com", { url: "https://existing.com" }); + + const urls = ["https://phishing.com", "https://malware.net"]; + const result = await service.addUrls(urls); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readwrite"); + expect(mockObjectStore.clear).not.toHaveBeenCalled(); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + // Existing data should still be present + expect(mockStore.has("https://existing.com")).toBe(true); + expect(mockStore.size).toBe(3); + expect(mockDb.close).toHaveBeenCalled(); + }); + + it("handles empty array without clearing", async () => { + mockStore.set("https://existing.com", { url: "https://existing.com" }); + + const result = await service.addUrls([]); + + expect(result).toBe(true); + expect(mockObjectStore.clear).not.toHaveBeenCalled(); + expect(mockStore.has("https://existing.com")).toBe(true); + }); + + it("trims whitespace from URLs", async () => { + const urls = [" https://example.com ", "\nhttps://test.org\n"]; + + await service.addUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://example.com" }); + expect(mockObjectStore.put).toHaveBeenCalledWith({ url: "https://test.org" }); + }); + + it("skips empty lines", async () => { + const urls = ["https://example.com", "", " ", "https://test.org"]; + + await service.addUrls(urls); + + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + }); + + it("handles duplicate URLs via upsert", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + + const urls = [ + "https://example.com", // Already exists + "https://test.org", + ]; + + const result = await service.addUrls(urls); + + expect(result).toBe(true); + expect(mockObjectStore.put).toHaveBeenCalledTimes(2); + expect(mockStore.size).toBe(2); + }); + + it("logs error and returns false on failure", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const result = await service.addUrls(["https://test.com"]); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Add failed", + expect.any(Error), + ); + }); + }); + describe("hasUrl", () => { it("returns true for existing URL", async () => { mockStore.set("https://example.com", { url: "https://example.com" }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts index 099839a38d9..fe0f10da221 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -53,6 +53,9 @@ export class PhishingIndexedDbService { * @returns `true` if save succeeded, `false` on error */ async saveUrls(urls: string[]): Promise { + this.logService.debug( + `[PhishingIndexedDbService] Clearing and saving ${urls.length} to the store...`, + ); let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -67,6 +70,29 @@ export class PhishingIndexedDbService { } } + /** + * Adds an array of phishing URLs to IndexedDB. + * Appends to existing data without clearing. + * + * @param urls - Array of phishing URLs to add + * @returns `true` if add succeeded, `false` on error + */ + async addUrls(urls: string[]): Promise { + this.logService.debug(`[PhishingIndexedDbService] Adding ${urls.length} to the store...`); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + await this.saveChunked(db, urls); + return true; + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Add failed", error); + return false; + } finally { + db?.close(); + } + } + /** * Saves URLs in chunks to prevent transaction timeouts and UI freezes. */ @@ -100,6 +126,8 @@ export class PhishingIndexedDbService { * @returns `true` if URL exists, `false` if not found or on error */ async hasUrl(url: string): Promise { + this.logService.debug(`[PhishingIndexedDbService] Checking if store contains ${url}...`); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -130,6 +158,8 @@ export class PhishingIndexedDbService { * @returns Array of all stored URLs, or empty array on error */ async loadAllUrls(): Promise { + this.logService.debug("[PhishingIndexedDbService] Loading all urls from store..."); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); @@ -173,11 +203,16 @@ export class PhishingIndexedDbService { * @returns `true` if save succeeded, `false` on error */ async saveUrlsFromStream(stream: ReadableStream): Promise { + this.logService.debug("[PhishingIndexedDbService] Saving urls to the store from stream..."); + let db: IDBDatabase | null = null; try { db = await this.openDatabase(); await this.clearStore(db); await this.processStream(db, stream); + this.logService.info( + "[PhishingIndexedDbService] Finished saving urls to the store from stream.", + ); return true; } catch (error) { this.logService.error("[PhishingIndexedDbService] Stream save failed", error); From d64db8fbf58a90769683c2b00c7cc0e4ac0d6180 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 26 Jan 2026 17:44:16 +0100 Subject: [PATCH 42/52] [CL-904] Migrate CL/Navigation to use OnPush (#16958) * Migrate CL/Navigation to use OnPush * Modernize the code * Swap to signals and class * Further tweaks * Remove this. * Replace setOpen and setClose with a public signal * fix merge issues and signal-ifying service * fix class and style bindings * fix accidental behavior change from merge conflicts * fix redundant check * fix missed ngClass * fix comment * Re-add share ng-template --------- Co-authored-by: Vicki League Co-authored-by: Will Martin Co-authored-by: Claude Sonnet 4.5 --- .../src/layout/layout.component.html | 23 ++-- .../src/navigation/nav-base.component.ts | 20 +-- .../src/navigation/nav-divider.component.html | 2 +- .../src/navigation/nav-divider.component.ts | 12 +- .../src/navigation/nav-group.component.html | 4 +- .../src/navigation/nav-group.component.ts | 42 +++--- .../src/navigation/nav-group.stories.ts | 5 +- .../src/navigation/nav-item.component.html | 69 +++++----- .../src/navigation/nav-item.component.ts | 62 +++++---- .../src/navigation/nav-logo.component.html | 15 +-- .../src/navigation/nav-logo.component.ts | 26 ++-- .../src/navigation/side-nav.component.html | 126 ++++++++---------- .../src/navigation/side-nav.component.ts | 30 +++-- .../src/navigation/side-nav.service.ts | 69 +++------- 14 files changed, 240 insertions(+), 265 deletions(-) diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 66bfcafafe9..f0e2b601e38 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -30,21 +30,14 @@ - @if ( - { - open: sideNavService.open$ | async, - }; - as data - ) { -
- @if (data.open) { -
- } -
- } +
+ @if (sideNavService.open()) { +
+ } +
diff --git a/libs/components/src/navigation/nav-base.component.ts b/libs/components/src/navigation/nav-base.component.ts index 706df2b25ad..e20edf5a0f9 100644 --- a/libs/components/src/navigation/nav-base.component.ts +++ b/libs/components/src/navigation/nav-base.component.ts @@ -1,8 +1,11 @@ -import { Directive, EventEmitter, Output, input, model } from "@angular/core"; +import { Directive, output, input, model } from "@angular/core"; import { RouterLink, RouterLinkActive } from "@angular/router"; /** - * `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`. + * Base class for navigation components in the side navigation. + * + * `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties + * that are passed down to `NavItemComponent`. */ @Directive() export abstract class NavBaseComponent { @@ -38,23 +41,26 @@ export abstract class NavBaseComponent { * * --- * + * @remarks * We can't name this "routerLink" because Angular will mount the `RouterLink` directive. * - * See: {@link https://github.com/angular/angular/issues/24482} + * @see {@link RouterLink.routerLink} + * @see {@link https://github.com/angular/angular/issues/24482} */ readonly route = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLink.relativeTo} + * @see {@link RouterLink.relativeTo} */ readonly relativeTo = input(); /** * Passed to internal `routerLink` * - * See {@link RouterLinkActive.routerLinkActiveOptions} + * @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" } + * @see {@link RouterLinkActive.routerLinkActiveOptions} */ readonly routerLinkActiveOptions = input({ paths: "subset", @@ -71,7 +77,5 @@ export abstract class NavBaseComponent { /** * Fires when main content is clicked */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() mainContentClicked: EventEmitter = new EventEmitter(); + readonly mainContentClicked = output(); } diff --git a/libs/components/src/navigation/nav-divider.component.html b/libs/components/src/navigation/nav-divider.component.html index 2d8e1dfa24b..7af7de2a28a 100644 --- a/libs/components/src/navigation/nav-divider.component.html +++ b/libs/components/src/navigation/nav-divider.component.html @@ -1,3 +1,3 @@ -@if (sideNavService.open$ | async) { +@if (sideNavService.open()) {
} diff --git a/libs/components/src/navigation/nav-divider.component.ts b/libs/components/src/navigation/nav-divider.component.ts index 2f33883fd58..05a69563312 100644 --- a/libs/components/src/navigation/nav-divider.component.ts +++ b/libs/components/src/navigation/nav-divider.component.ts @@ -1,15 +1,15 @@ -import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * A visual divider for separating navigation items in the side navigation. + */ @Component({ selector: "bit-nav-divider", templateUrl: "./nav-divider.component.html", - imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavDividerComponent { - constructor(protected sideNavService: SideNavService) {} + protected readonly sideNavService = inject(SideNavService); } diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index d305f89063e..26d1c68da43 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -20,9 +20,7 @@ - + } @if (open) {
}
+ + + +
+ @if (icon()) { + + } + @if (open) { + {{ text() }} + } +
+
diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index e57413d9980..53b181ec083 100644 --- a/libs/components/src/navigation/nav-item.component.ts +++ b/libs/components/src/navigation/nav-item.component.ts @@ -1,7 +1,14 @@ -import { CommonModule } from "@angular/common"; -import { Component, HostListener, Optional, computed, input, model } from "@angular/core"; -import { RouterLinkActive, RouterModule } from "@angular/router"; -import { BehaviorSubject, map } from "rxjs"; +import { NgTemplateOutlet } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + input, + inject, + signal, + computed, + model, +} from "@angular/core"; +import { RouterModule, RouterLinkActive } from "@angular/router"; import { IconButtonModule } from "../icon-button"; @@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction { abstract treeDepth: ReturnType>; } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-item", templateUrl: "./nav-item.component.html", providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }], - imports: [CommonModule, IconButtonModule, RouterModule], + imports: [NgTemplateOutlet, IconButtonModule, RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "(focusin)": "onFocusIn($event.target)", + "(focusout)": "onFocusOut()", + }, }) export class NavItemComponent extends NavBaseComponent { /** @@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent { */ protected readonly TREE_DEPTH_PADDING = 1.75; - /** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */ + /** + * Forces active styles to be shown, regardless of the `routerLinkActiveOptions` + */ readonly forceActiveStyles = input(false); + protected readonly sideNavService = inject(SideNavService); + private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true }); + /** * Is `true` if `to` matches the current route */ @@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent { * adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels */ protected readonly navItemIndentationPadding = computed(() => { - const open = this.sideNavService.open; + const open = this.sideNavService.open(); const depth = this.treeDepth() ?? 0; if (open && this.variant() === "tree") { @@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent { * (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some * styles, so the entire component can have an outline. */ - protected focusVisibleWithin$ = new BehaviorSubject(false); - protected fvwStyles$ = this.focusVisibleWithin$.pipe( - map((value) => - value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" : "", - ), + protected readonly focusVisibleWithin = signal(false); + protected readonly fvwStyles = computed(() => + this.focusVisibleWithin() + ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus" + : "", ); - @HostListener("focusin", ["$event.target"]) - onFocusIn(target: HTMLElement) { - this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible")); - } - @HostListener("focusout") - onFocusOut() { - this.focusVisibleWithin$.next(false); + + protected onFocusIn(target: HTMLElement) { + this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible")); } - constructor( - protected sideNavService: SideNavService, - @Optional() private parentNavGroup: NavGroupAbstraction, - ) { + protected onFocusOut() { + this.focusVisibleWithin.set(false); + } + + constructor() { super(); // Set tree depth based on parent's depth diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 1d9961554c2..9f18855ae13 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -1,22 +1,21 @@ diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index 0602e8b753c..fec50ee8902 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,5 +1,4 @@ -import { CommonModule } from "@angular/common"; -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; @@ -8,18 +7,25 @@ import { BitIconComponent } from "../icon/icon.component"; import { SideNavService } from "./side-nav.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, BitIconComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { - /** Icon that is displayed when the side nav is closed */ + protected readonly sideNavService = inject(SideNavService); + + /** + * Icon that is displayed when the side nav is closed + * + * @default BitwardenShield + */ readonly closedIcon = input(BitwardenShield); - /** Icon that is displayed when the side nav is open */ + /** + * Icon that is displayed when the side nav is open + */ readonly openIcon = input.required(); /** @@ -27,8 +33,8 @@ export class NavLogoComponent { */ readonly route = input.required(); - /** Passed to `attr.aria-label` and `attr.title` */ + /** + * Passed to `attr.aria-label` and `attr.title` + */ readonly label = input.required(); - - constructor(protected sideNavService: SideNavService) {} } diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index b70d650622a..78fed07011d 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -1,68 +1,60 @@ -@if ( - { - open: sideNavService.open$ | async, - isOverlay: sideNavService.isOverlay$ | async, - }; - as data -) { -
- +@let open = sideNavService.open(); +@let isOverlay = sideNavService.isOverlay(); + +
+
-} + class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav" + > + + @if (open) { + + } +
+ +
+
+ + +
diff --git a/libs/components/src/navigation/side-nav.component.ts b/libs/components/src/navigation/side-nav.component.ts index b13920d9749..35835f1be96 100644 --- a/libs/components/src/navigation/side-nav.component.ts +++ b/libs/components/src/navigation/side-nav.component.ts @@ -1,7 +1,14 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop"; -import { CommonModule } from "@angular/common"; -import { Component, ElementRef, inject, input, viewChild } from "@angular/core"; +import { AsyncPipe } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + input, + viewChild, + inject, +} from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service"; export type SideNavVariant = "primary" | "secondary"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Side navigation component that provides a collapsible navigation menu. + */ @Component({ selector: "bit-side-nav", templateUrl: "side-nav.component.html", imports: [ - CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe, DragDropModule, + AsyncPipe, ], host: { class: "tw-block tw-h-full", }, + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SideNavComponent { - protected sideNavService = inject(SideNavService); + protected readonly sideNavService = inject(SideNavService); + /** + * Visual variant of the side navigation + * + * @default "primary" + */ readonly variant = input("primary"); private readonly toggleButton = viewChild("toggleButton", { read: ElementRef }); private elementRef = inject>(ElementRef); - protected handleKeyDown = (event: KeyboardEvent) => { + protected readonly handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - this.sideNavService.setClose(); + this.sideNavService.open.set(false); this.toggleButton()?.nativeElement.focus(); return false; } diff --git a/libs/components/src/navigation/side-nav.service.ts b/libs/components/src/navigation/side-nav.service.ts index 63e54c81fe5..05713006a43 100644 --- a/libs/components/src/navigation/side-nav.service.ts +++ b/libs/components/src/navigation/side-nav.service.ts @@ -1,15 +1,6 @@ -import { inject, Injectable } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { - BehaviorSubject, - Observable, - combineLatest, - fromEvent, - map, - startWith, - debounceTime, - first, -} from "rxjs"; +import { computed, effect, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs"; import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state"; @@ -32,16 +23,17 @@ export class SideNavService { private rootFontSizePx: number; - private _open$ = new BehaviorSubject(isAtOrLargerThanBreakpoint("md")); - open$ = this._open$.asObservable(); + /** + * Whether the side navigation is open or closed. + */ + readonly open = signal(isAtOrLargerThanBreakpoint("md")); private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`); - private _userCollapsePreference$ = new BehaviorSubject(null); - userCollapsePreference$ = this._userCollapsePreference$.asObservable(); + readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true }); - isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe( - map(([open, isLargeScreen]) => open && !isLargeScreen), - ); + readonly userCollapsePreference = signal(null); + + readonly isOverlay = computed(() => this.open() && !this.isLargeScreen()); /** * Local component state width @@ -67,16 +59,14 @@ export class SideNavService { this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16"); // Handle open/close state - combineLatest([this.isLargeScreen$, this.userCollapsePreference$]) - .pipe(takeUntilDestroyed()) - .subscribe(([isLargeScreen, userCollapsePreference]) => { - if (!isLargeScreen) { - this.setClose(); - } else if (userCollapsePreference !== "closed") { - // Auto-open when user hasn't set preference (null) or prefers open - this.setOpen(); - } - }); + effect(() => { + if (!this.isLargeScreen()) { + this.open.set(false); + } else if (this.userCollapsePreference() !== "closed") { + // Auto-open when user hasn't set preference (null) or prefers open + this.open.set(true); + } + }); // Initialize the resizable width from state provider this.widthState$.pipe(first()).subscribe((width: number) => { @@ -89,31 +79,14 @@ export class SideNavService { }); } - get open() { - return this._open$.getValue(); - } - - setOpen() { - this._open$.next(true); - } - - setClose() { - this._open$.next(false); - } - /** * Toggle the open/close state of the side nav */ toggle() { - const curr = this._open$.getValue(); // Store user's preference based on what state they're toggling TO - this._userCollapsePreference$.next(curr ? "closed" : "open"); + this.userCollapsePreference.set(this.open() ? "closed" : "open"); - if (curr) { - this.setClose(); - } else { - this.setOpen(); - } + this.open.set(!this.open()); } /** From d459e81319f9670d78156f707295641c7b853ccf Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:22:07 -0600 Subject: [PATCH 43/52] upgrade node-fetch (#18482) --- apps/cli/package.json | 2 +- package-lock.json | 59 ++++++++----------------------------------- package.json | 4 +-- 3 files changed, 13 insertions(+), 52 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index a19c811b4bf..c80f79feff8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -81,7 +81,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", diff --git a/package-lock.json b/package-lock.json index 42206a1b46c..2cd18e11adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0", @@ -110,7 +110,7 @@ "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", "@types/node": "22.19.3", - "@types/node-fetch": "2.6.4", + "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", "@types/proper-lockfile": "4.1.4", @@ -217,7 +217,7 @@ "lowdb": "1.0.0", "lunr": "2.3.9", "multer": "2.0.2", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "open": "11.0.0", "papaparse": "5.5.3", @@ -15842,53 +15842,14 @@ } }, "node_modules/@types/node-fetch": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.4.tgz", - "integrity": "sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==", + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "form-data": "^3.0.0" - } - }, - "node_modules/@types/node-fetch/node_modules/form-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", - "integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/node-fetch/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@types/node-fetch/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "form-data": "^4.0.4" } }, "node_modules/@types/node-forge": { @@ -32816,9 +32777,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" diff --git a/package.json b/package.json index 829dc91370a..8455d97c87c 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@types/lowdb": "1.0.15", "@types/lunr": "2.3.7", "@types/node": "22.19.3", - "@types/node-fetch": "2.6.4", + "@types/node-fetch": "2.6.13", "@types/node-forge": "1.3.14", "@types/papaparse": "5.5.0", "@types/proper-lockfile": "4.1.4", @@ -191,7 +191,7 @@ "lunr": "2.3.9", "multer": "2.0.2", "ngx-toastr": "19.1.0", - "node-fetch": "2.6.12", + "node-fetch": "2.7.0", "node-forge": "1.3.2", "oidc-client-ts": "2.4.1", "open": "11.0.0", From 87555eaabdb0703010e478f28283479f2611d01f Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:07:31 -0600 Subject: [PATCH 44/52] remove risk insights for premium feature flag (#18446) --- libs/common/src/enums/feature-flag.enum.ts | 2 -- .../cipher-view/cipher-view.component.spec.ts | 21 ------------------- .../src/cipher-view/cipher-view.component.ts | 19 +++-------------- 3 files changed, 3 insertions(+), 39 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index f761aea1b08..77df258ad3a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -65,7 +65,6 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", @@ -129,7 +128,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, - [FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, diff --git a/libs/vault/src/cipher-view/cipher-view.component.spec.ts b/libs/vault/src/cipher-view/cipher-view.component.spec.ts index 18a5132781b..2300565035e 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.spec.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.spec.ts @@ -8,7 +8,6 @@ import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -42,11 +41,9 @@ describe("CipherViewComponent", () => { let mockLogService: LogService; let mockCipherRiskService: CipherRiskService; let mockBillingAccountProfileStateService: BillingAccountProfileStateService; - let mockConfigService: ConfigService; // Mock data let mockCipherView: CipherView; - let featureFlagEnabled$: BehaviorSubject; let hasPremiumFromAnySource$: BehaviorSubject; let activeAccount$: BehaviorSubject; @@ -57,7 +54,6 @@ describe("CipherViewComponent", () => { email: "test@example.com", } as Account); - featureFlagEnabled$ = new BehaviorSubject(false); hasPremiumFromAnySource$ = new BehaviorSubject(true); // Create service mocks @@ -83,9 +79,6 @@ describe("CipherViewComponent", () => { .fn() .mockReturnValue(hasPremiumFromAnySource$); - mockConfigService = mock(); - mockConfigService.getFeatureFlag$ = jest.fn().mockReturnValue(featureFlagEnabled$); - // Setup mock cipher view mockCipherView = new CipherView(); mockCipherView.id = "cipher-id"; @@ -110,7 +103,6 @@ describe("CipherViewComponent", () => { provide: BillingAccountProfileStateService, useValue: mockBillingAccountProfileStateService, }, - { provide: ConfigService, useValue: mockConfigService }, ], schemas: [NO_ERRORS_SCHEMA], }) @@ -145,7 +137,6 @@ describe("CipherViewComponent", () => { beforeEach(() => { // Reset observables to default values for this test suite - featureFlagEnabled$.next(true); hasPremiumFromAnySource$.next(true); // Setup default mock for computeCipherRiskForUser (individual tests can override) @@ -162,18 +153,6 @@ describe("CipherViewComponent", () => { component = fixture.componentInstance; }); - it("returns false when feature flag is disabled", fakeAsync(() => { - featureFlagEnabled$.next(false); - - const cipher = createLoginCipherView(); - fixture.componentRef.setInput("cipher", cipher); - fixture.detectChanges(); - tick(); - - expect(mockCipherRiskService.computeCipherRiskForUser).not.toHaveBeenCalled(); - expect(component.passwordIsAtRisk()).toBe(false); - })); - it("returns false when cipher has no login password", fakeAsync(() => { const cipher = createLoginCipherView(); cipher.login = {} as any; // No password diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index b5c063df51e..26e3f18b542 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -13,8 +13,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { isCardExpired } from "@bitwarden/common/autofill/utils"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getByIds } from "@bitwarden/common/platform/misc"; @@ -113,7 +111,6 @@ export class CipherViewComponent { private logService: LogService, private cipherRiskService: CipherRiskService, private billingAccountService: BillingAccountProfileStateService, - private configService: ConfigService, ) {} readonly resolvedCollections = toSignal( @@ -248,19 +245,9 @@ export class CipherViewComponent { * The password is only evaluated when the user is premium and has edit access to the cipher. */ readonly passwordIsAtRisk = toSignal( - combineLatest([ - this.activeUserId$, - this.cipher$, - this.configService.getFeatureFlag$(FeatureFlag.RiskInsightsForPremium), - ]).pipe( - switchMap(([userId, cipher, featureEnabled]) => { - if ( - !featureEnabled || - !cipher.hasLoginPassword || - !cipher.edit || - cipher.organizationId || - cipher.isDeleted - ) { + combineLatest([this.activeUserId$, this.cipher$]).pipe( + switchMap(([userId, cipher]) => { + if (!cipher.hasLoginPassword || !cipher.edit || cipher.organizationId || cipher.isDeleted) { return of(false); } return this.switchPremium$( From 06c8c7316d71b1d3a799a29dde55e88ea9ad2d1b Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 26 Jan 2026 11:43:35 -0800 Subject: [PATCH 45/52] [PM-30301][PM-30302] Use SDK for Create and Update cipher operations (#18149) * Migrate create and edit operations to use SDK for ciphers * WIP: Adds admin call to edit ciphers with SDK * Add client version to SDK intialization settings * Remove console.log statements * Adds originalCipherId and collectionIds to updateCipher * Update tests for new cipehrService interfaces * Rename SdkCipherOperations feature flag * Add call to Admin edit SDK if flag is passed * Add tests for SDK path * Revert changes to .npmrc * Remove outdated comments * Fix feature flag name * Fix UUID format in cipher.service.spec.ts * Update calls to cipherService.updateWithServer and .createWithServer to new interface * Update CLI and Desktop to use new cipherSErvice interfaces * Fix tests for new cipherService interface change * Bump sdk-internal and commercial-sdk-internal versions to 0.2.0-main.439 * Fix linting errors * Fix typescript errors impacted by this chnage * Fix caching issue on browser extension when using SDK cipher ops. * Remove commented code * Fix bug causing race condition due to not consuming / awaiting observable. * Add missing 'await' to decrypt call * Clean up unnecessary else statements and fix function naming * Add comments for this.clearCache * Add tests for SDK CipherView conversion functions * Replace sdkservice with cipher-sdk.service * Fix import issues in browser * Fix import issues in cli * Fix type issues * Fix type issues * Fix type issues * Fix test that fails sporadically due to timing issue --- .../notification.background.spec.ts | 19 +- .../background/notification.background.ts | 9 +- .../autofill/popup/fido2/fido2.component.ts | 5 +- .../browser/src/background/main.background.ts | 6 + .../item-more-options.component.ts | 3 +- .../vault-popup-autofill.service.spec.ts | 3 +- .../services/vault-popup-autofill.service.ts | 3 +- apps/cli/src/commands/edit.command.ts | 4 +- .../service-container/service-container.ts | 6 + apps/cli/src/vault/create.command.ts | 9 +- .../desktop-fido2-user-interface.service.ts | 10 +- .../encrypted-message-handler.service.ts | 6 +- .../vault/individual-vault/vault.component.ts | 3 +- .../src/services/jslib-services.module.ts | 10 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../fido2/fido2-authenticator.service.spec.ts | 31 +- .../fido2/fido2-authenticator.service.ts | 6 +- .../services/sdk/default-sdk.service.ts | 7 +- .../services/sdk/register-sdk.service.ts | 7 +- .../vault/abstractions/cipher-sdk.service.ts | 37 ++ .../src/vault/abstractions/cipher.service.ts | 13 +- .../src/vault/models/view/cipher.view.spec.ts | 362 ++++++++++++++++++ .../src/vault/models/view/cipher.view.ts | 86 ++++- .../vault/services/cipher-sdk.service.spec.ts | 246 ++++++++++++ .../src/vault/services/cipher-sdk.service.ts | 82 ++++ .../src/vault/services/cipher.service.spec.ts | 164 +++++++- .../src/vault/services/cipher.service.ts | 75 +++- .../services/default-cipher-form.service.ts | 37 +- 28 files changed, 1126 insertions(+), 125 deletions(-) create mode 100644 libs/common/src/vault/abstractions/cipher-sdk.service.ts create mode 100644 libs/common/src/vault/services/cipher-sdk.service.spec.ts create mode 100644 libs/common/src/vault/services/cipher-sdk.service.ts diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index ab16788ea6f..a927c75dba0 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -767,7 +767,6 @@ describe("NotificationBackground", () => { let createWithServerSpy: jest.SpyInstance; let updateWithServerSpy: jest.SpyInstance; let folderExistsSpy: jest.SpyInstance; - let cipherEncryptSpy: jest.SpyInstance; beforeEach(() => { activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -791,7 +790,6 @@ describe("NotificationBackground", () => { createWithServerSpy = jest.spyOn(cipherService, "createWithServer"); updateWithServerSpy = jest.spyOn(cipherService, "updateWithServer"); folderExistsSpy = jest.spyOn(notificationBackground as any, "folderExists"); - cipherEncryptSpy = jest.spyOn(cipherService, "encrypt"); accountService.activeAccount$ = activeAccountSubject; }); @@ -1190,13 +1188,7 @@ describe("NotificationBackground", () => { folderExistsSpy.mockResolvedValueOnce(false); convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView); editItemSpy.mockResolvedValueOnce(undefined); - cipherEncryptSpy.mockResolvedValueOnce({ - cipher: { - ...cipherView, - id: "testId", - }, - encryptedFor: userId, - }); + createWithServerSpy.mockResolvedValueOnce(cipherView); sendMockExtensionMessage(message, sender); await flushPromises(); @@ -1205,7 +1197,6 @@ describe("NotificationBackground", () => { queueMessage, null, ); - expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId"); expect(createWithServerSpy).toHaveBeenCalled(); expect(tabSendMessageDataSpy).toHaveBeenCalledWith( sender.tab, @@ -1241,13 +1232,6 @@ describe("NotificationBackground", () => { folderExistsSpy.mockResolvedValueOnce(true); convertAddLoginQueueMessageToCipherViewSpy.mockReturnValueOnce(cipherView); editItemSpy.mockResolvedValueOnce(undefined); - cipherEncryptSpy.mockResolvedValueOnce({ - cipher: { - ...cipherView, - id: "testId", - }, - encryptedFor: userId, - }); const errorMessage = "fetch error"; createWithServerSpy.mockImplementation(() => { throw new Error(errorMessage); @@ -1256,7 +1240,6 @@ describe("NotificationBackground", () => { sendMockExtensionMessage(message, sender); await flushPromises(); - expect(cipherEncryptSpy).toHaveBeenCalledWith(cipherView, "testId"); expect(createWithServerSpy).toThrow(errorMessage); expect(tabSendMessageSpy).not.toHaveBeenCalledWith(sender.tab, { command: "addedCipher", diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 1cbf915b06a..f8459cf8f23 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -866,13 +866,11 @@ export default class NotificationBackground { return; } - const encrypted = await this.cipherService.encrypt(newCipher, activeUserId); - const { cipher } = encrypted; try { - await this.cipherService.createWithServer(encrypted); + const resultCipher = await this.cipherService.createWithServer(newCipher, activeUserId); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { itemName: newCipher?.name && String(newCipher?.name), - cipherId: cipher?.id && String(cipher?.id), + cipherId: resultCipher?.id && String(resultCipher?.id), }); await BrowserApi.tabSendMessage(tab, { command: "addedCipher" }); } catch (error) { @@ -910,7 +908,6 @@ export default class NotificationBackground { await BrowserApi.tabSendMessage(tab, { command: "editedCipher" }); return; } - const cipher = await this.cipherService.encrypt(cipherView, userId); try { if (!cipherView.edit) { @@ -939,7 +936,7 @@ export default class NotificationBackground { return; } - await this.cipherService.updateWithServer(cipher); + await this.cipherService.updateWithServer(cipherView, userId); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { itemName: cipherView?.name && String(cipherView?.name), diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index c1982d27d24..5720419f909 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -444,10 +444,9 @@ export class Fido2Component implements OnInit, OnDestroy { ); this.buildCipher(name, username); - const encrypted = await this.cipherService.encrypt(this.cipher, activeUserId); try { - await this.cipherService.createWithServer(encrypted); - this.cipher.id = encrypted.cipher.id; + const result = await this.cipherService.createWithServer(this.cipher, activeUserId); + this.cipher.id = result.id; } catch (e) { this.logService.error(e); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 58a7eb99ec6..660fcb97bcf 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -194,6 +194,7 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service" import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -211,6 +212,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; @@ -367,6 +369,7 @@ export default class MainBackground { apiService: ApiServiceAbstraction; hibpApiService: HibpApiService; environmentService: BrowserEnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherServiceAbstraction; folderService: InternalFolderServiceAbstraction; userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction; @@ -973,6 +976,8 @@ export default class MainBackground { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -988,6 +993,7 @@ export default class MainBackground { this.logService, this.cipherEncryptionService, this.messagingService, + this.cipherSdkService, ); this.folderService = new FolderService( this.keyService, diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index f881b07282b..d7de51ad20f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -277,8 +277,7 @@ export class ItemMoreOptionsComponent { this.accountService.activeAccount$.pipe(map((a) => a?.id)), )) as UserId; - const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(encryptedCipher); + await this.cipherService.updateWithServer(cipher, activeUserId); this.toastService.showToast({ variant: "success", message: this.i18nService.t( diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts index 5818c6e32ff..94542009a89 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.spec.ts @@ -378,8 +378,7 @@ describe("VaultPopupAutofillService", () => { expect(result).toBe(true); expect(mockCipher.login.uris).toHaveLength(1); expect(mockCipher.login.uris[0].uri).toBe(mockCurrentTab.url); - expect(mockCipherService.encrypt).toHaveBeenCalledWith(mockCipher, mockUserId); - expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockEncryptedCipher); + expect(mockCipherService.updateWithServer).toHaveBeenCalledWith(mockCipher, mockUserId); }); it("should add a URI to the cipher when there are no existing URIs", async () => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts index 6feeec29efc..025088e029e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-autofill.service.ts @@ -426,8 +426,7 @@ export class VaultPopupAutofillService { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); - const encCipher = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(encCipher); + await this.cipherService.updateWithServer(cipher, activeUserId); this.messagingService.send("editedCipher"); return true; } catch { diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index d95e8333dca..dbcb0489187 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -138,10 +138,8 @@ export class EditCommand { ); } - const encCipher = await this.cipherService.encrypt(cipherView, activeUserId); try { - const updatedCipher = await this.cipherService.updateWithServer(encCipher); - const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId); + const decCipher = await this.cipherService.updateWithServer(cipherView, activeUserId); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index bc3d3153b13..7bb8da27040 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -147,11 +147,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service" import { UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; @@ -254,6 +256,7 @@ export class ServiceContainer { twoFactorApiService: TwoFactorApiService; hibpApiService: HibpApiService; environmentService: EnvironmentService; + cipherSdkService: CipherSdkService; cipherService: CipherService; folderService: InternalFolderService; organizationUserApiService: OrganizationUserApiService; @@ -794,6 +797,8 @@ export class ServiceContainer { this.logService, ); + this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService); + this.cipherService = new CipherService( this.keyService, this.domainSettingsService, @@ -809,6 +814,7 @@ export class ServiceContainer { this.logService, this.cipherEncryptionService, this.messagingService, + this.cipherSdkService, ); this.cipherArchiveService = new DefaultCipherArchiveService( diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index d826766dc65..e1a91966afd 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -103,10 +103,11 @@ export class CreateCommand { return Response.error("Creating this item type is restricted by organizational policy."); } - const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId); - const newCipher = await this.cipherService.createWithServer(cipher); - const decCipher = await this.cipherService.decrypt(newCipher, activeUserId); - const res = new CipherResponse(decCipher); + const newCipher = await this.cipherService.createWithServer( + CipherExport.toView(req), + activeUserId, + ); + const res = new CipherResponse(newCipher); return Response.success(res); } catch (e) { return Response.error(e); diff --git a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts index cf29370840d..432448faba3 100644 --- a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts +++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts @@ -299,12 +299,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi throw new Error("No active user ID found!"); } - const encCipher = await this.cipherService.encrypt(cipher, activeUserId); - try { - const createdCipher = await this.cipherService.createWithServer(encCipher); + const createdCipher = await this.cipherService.createWithServer(cipher, activeUserId); + const encryptedCreatedCipher = await this.cipherService.encrypt(createdCipher, activeUserId); - return createdCipher; + return encryptedCreatedCipher.cipher; } catch { throw new Error("Unable to create cipher"); } @@ -316,8 +315,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi this.accountService.activeAccount$.pipe( map(async (a) => { if (a) { - const encCipher = await this.cipherService.encrypt(cipher, a.id); - await this.cipherService.updateWithServer(encCipher); + await this.cipherService.updateWithServer(cipher, a.id); } }), ), diff --git a/apps/desktop/src/services/encrypted-message-handler.service.ts b/apps/desktop/src/services/encrypted-message-handler.service.ts index 366a144c021..ccbc7c539d0 100644 --- a/apps/desktop/src/services/encrypted-message-handler.service.ts +++ b/apps/desktop/src/services/encrypted-message-handler.service.ts @@ -166,8 +166,7 @@ export class EncryptedMessageHandlerService { try { const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const encrypted = await this.cipherService.encrypt(cipherView, activeUserId); - await this.cipherService.createWithServer(encrypted); + await this.cipherService.createWithServer(cipherView, activeUserId); // Notify other clients of new login await this.messagingService.send("addedCipher"); @@ -212,9 +211,8 @@ export class EncryptedMessageHandlerService { cipherView.login.password = credentialUpdatePayload.password; cipherView.login.username = credentialUpdatePayload.userName; cipherView.login.uris[0].uri = credentialUpdatePayload.uri; - const encrypted = await this.cipherService.encrypt(cipherView, activeUserId); - await this.cipherService.updateWithServer(encrypted); + await this.cipherService.updateWithServer(cipherView, activeUserId); // Notify other clients of update await this.messagingService.send("editedCipher"); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 5ca3a11d5ab..532757852a3 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -1536,8 +1536,7 @@ export class VaultComponent implements OnInit, OnDestr const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const cipherFullView = await this.cipherService.getFullCipherView(cipher); cipherFullView.favorite = !cipherFullView.favorite; - const encryptedCipher = await this.cipherService.encrypt(cipherFullView, activeUserId); - await this.cipherService.updateWithServer(encryptedCipher); + await this.cipherService.updateWithServer(cipherFullView, activeUserId); this.toastService.showToast({ variant: "success", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 7b504548ff5..1ecf7fe3e3d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -303,6 +303,7 @@ import { import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service"; +import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; @@ -321,6 +322,7 @@ import { CipherAuthorizationService, DefaultCipherAuthorizationService, } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service"; import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service"; @@ -590,6 +592,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultDomainSettingsService, deps: [StateProvider, PolicyServiceAbstraction, AccountService], }), + safeProvider({ + provide: CipherSdkService, + useClass: DefaultCipherSdkService, + deps: [SdkService, LogService], + }), safeProvider({ provide: CipherServiceAbstraction, useFactory: ( @@ -607,6 +614,7 @@ const safeProviders: SafeProvider[] = [ logService: LogService, cipherEncryptionService: CipherEncryptionService, messagingService: MessagingServiceAbstraction, + cipherSdkService: CipherSdkService, ) => new CipherService( keyService, @@ -623,6 +631,7 @@ const safeProviders: SafeProvider[] = [ logService, cipherEncryptionService, messagingService, + cipherSdkService, ), deps: [ KeyService, @@ -639,6 +648,7 @@ const safeProviders: SafeProvider[] = [ LogService, CipherEncryptionService, MessagingServiceAbstraction, + CipherSdkService, ], }), safeProvider({ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 77df258ad3a..94656d48826 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -68,6 +68,7 @@ export enum FeatureFlag { VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", + PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -130,6 +131,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, + [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, /* Auth */ diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 9c50bd1ab65..6223e4274bf 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -254,17 +254,17 @@ describe("FidoAuthenticatorService", () => { } it("should save credential to vault if request confirmed by user", async () => { - const encryptedCipher = Symbol(); userInterfaceSession.confirmNewCredential.mockResolvedValue({ cipherId: existingCipher.id, userVerified: false, }); - cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as EncryptionContext); await authenticator.makeCredential(params, windowReference); - const saved = cipherService.encrypt.mock.lastCall?.[0]; - expect(saved).toEqual( + const savedCipher = cipherService.updateWithServer.mock.lastCall?.[0]; + const actualUserId = cipherService.updateWithServer.mock.lastCall?.[1]; + expect(actualUserId).toEqual(userId); + expect(savedCipher).toEqual( expect.objectContaining({ type: CipherType.Login, name: existingCipher.name, @@ -288,7 +288,6 @@ describe("FidoAuthenticatorService", () => { }), }), ); - expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher); }); /** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */ @@ -361,17 +360,14 @@ describe("FidoAuthenticatorService", () => { cipherService.getAllDecrypted.mockResolvedValue([await cipher]); cipherService.decrypt.mockResolvedValue(cipher); - cipherService.encrypt.mockImplementation(async (cipher) => { - cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability - return { cipher: {} as any as Cipher, encryptedFor: userId }; - }); - cipherService.createWithServer.mockImplementation(async ({ cipher }) => { - cipher.id = cipherId; + cipherService.createWithServer.mockImplementation(async (cipherView, _userId) => { + cipherView.id = cipherId; return cipher; }); - cipherService.updateWithServer.mockImplementation(async ({ cipher }) => { - cipher.id = cipherId; - return cipher; + cipherService.updateWithServer.mockImplementation(async (cipherView, _userId) => { + cipherView.id = cipherId; + cipherView.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability + return cipherView; }); }); @@ -701,14 +697,11 @@ describe("FidoAuthenticatorService", () => { /** Spec: Increment the credential associated signature counter */ it("should increment counter and save to server when stored counter is larger than zero", async () => { - const encrypted = Symbol(); - cipherService.encrypt.mockResolvedValue(encrypted as any); ciphers[0].login.fido2Credentials[0].counter = 9000; await authenticator.getAssertion(params, windowReference); - expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); - expect(cipherService.encrypt).toHaveBeenCalledWith( + expect(cipherService.updateWithServer).toHaveBeenCalledWith( expect.objectContaining({ id: ciphers[0].id, login: expect.objectContaining({ @@ -725,8 +718,6 @@ describe("FidoAuthenticatorService", () => { /** Spec: Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero. */ it("should not save to server when stored counter is zero", async () => { - const encrypted = Symbol(); - cipherService.encrypt.mockResolvedValue(encrypted as any); ciphers[0].login.fido2Credentials[0].counter = 0; await authenticator.getAssertion(params, windowReference); diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index d1081e9f7b2..1b150207290 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -187,8 +187,7 @@ export class Fido2AuthenticatorService< if (Utils.isNullOrEmpty(cipher.login.username)) { cipher.login.username = fido2Credential.userName; } - const reencrypted = await this.cipherService.encrypt(cipher, activeUserId); - await this.cipherService.updateWithServer(reencrypted); + await this.cipherService.updateWithServer(cipher, activeUserId); await this.cipherService.clearCache(activeUserId); credentialId = fido2Credential.credentialId; } catch (error) { @@ -328,8 +327,7 @@ export class Fido2AuthenticatorService< const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(getUserId), ); - const encrypted = await this.cipherService.encrypt(selectedCipher, activeUserId); - await this.cipherService.updateWithServer(encrypted); + await this.cipherService.updateWithServer(selectedCipher, activeUserId); await this.cipherService.clearCache(activeUserId); } diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 5084f5f5f18..e2c9c77e204 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -80,7 +80,7 @@ export class DefaultSdkService implements SdkService { client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { await SdkLoadService.Ready; - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService), settings, @@ -210,7 +210,7 @@ export class DefaultSdkService implements SdkService { return undefined; } - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService, userId), settings, @@ -322,11 +322,12 @@ export class DefaultSdkService implements SdkService { client.platform().load_flags(featureFlagMap); } - private toSettings(env: Environment): ClientSettings { + private async toSettings(env: Environment): Promise { return { apiUrl: env.getApiUrl(), identityUrl: env.getIdentityUrl(), deviceType: toSdkDevice(this.platformUtilsService.getDevice()), + bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(), userAgent: this.userAgent ?? navigator.userAgent, }; } diff --git a/libs/common/src/platform/services/sdk/register-sdk.service.ts b/libs/common/src/platform/services/sdk/register-sdk.service.ts index a222807640f..073c5c0560c 100644 --- a/libs/common/src/platform/services/sdk/register-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/register-sdk.service.ts @@ -62,7 +62,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService { client$ = this.environmentService.environment$.pipe( concatMap(async (env) => { await SdkLoadService.Ready; - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService), settings, @@ -137,7 +137,7 @@ export class DefaultRegisterSdkService implements RegisterSdkService { return undefined; } - const settings = this.toSettings(env); + const settings = await this.toSettings(env); const client = await this.sdkClientFactory.createSdkClient( new JsTokenProvider(this.apiService, userId), settings, @@ -185,12 +185,13 @@ export class DefaultRegisterSdkService implements RegisterSdkService { client.platform().load_flags(featureFlagMap); } - private toSettings(env: Environment): ClientSettings { + private async toSettings(env: Environment): Promise { return { apiUrl: env.getApiUrl(), identityUrl: env.getIdentityUrl(), deviceType: toSdkDevice(this.platformUtilsService.getDevice()), userAgent: this.userAgent ?? navigator.userAgent, + bitwardenClientVersion: await this.platformUtilsService.getApplicationVersionNumber(), }; } } diff --git a/libs/common/src/vault/abstractions/cipher-sdk.service.ts b/libs/common/src/vault/abstractions/cipher-sdk.service.ts new file mode 100644 index 00000000000..1037bfc2b92 --- /dev/null +++ b/libs/common/src/vault/abstractions/cipher-sdk.service.ts @@ -0,0 +1,37 @@ +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +/** + * Service responsible for cipher operations using the SDK. + */ +export abstract class CipherSdkService { + /** + * Creates a new cipher on the server using the SDK. + * + * @param cipherView The cipher view to create + * @param userId The user ID to use for SDK client + * @param orgAdmin Whether this is an organization admin operation + * @returns A promise that resolves to the created cipher view + */ + abstract createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise; + + /** + * Updates a cipher on the server using the SDK. + * + * @param cipher The cipher view to update + * @param userId The user ID to use for SDK client + * @param originalCipherView The original cipher view before changes (optional, used for admin operations) + * @param orgAdmin Whether this is an organization admin operation + * @returns A promise that resolves to the updated cipher view + */ + abstract updateWithServer( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise; +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 203984075f7..1db5f8d38a7 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -119,9 +119,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + ): Promise; + /** * Update a cipher with the server * @param cipher The cipher to update @@ -131,10 +133,11 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + ): Promise; /** * Move a cipher to an organization by re-encrypting its keys with the organization's key. diff --git a/libs/common/src/vault/models/view/cipher.view.spec.ts b/libs/common/src/vault/models/view/cipher.view.spec.ts index 475fe9e23f3..1c7017d5d89 100644 --- a/libs/common/src/vault/models/view/cipher.view.spec.ts +++ b/libs/common/src/vault/models/view/cipher.view.spec.ts @@ -353,4 +353,366 @@ describe("CipherView", () => { }); }); }); + + // Note: These tests use jest.requireActual() because the file has jest.mock() calls + // at the top that mock LoginView, FieldView, etc. Those mocks are needed for other tests + // but interfere with these tests which need the real implementations. + describe("toSdkCreateCipherRequest", () => { + it("maps all properties correctly for a login cipher", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.collectionIds = ["b0473506-3c3c-4260-a734-dfaaf833ab6f"]; + cipherView.name = "Test Login"; + cipherView.notes = "Test notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + + const field = new RealFieldView(); + field.name = "testField"; + field.value = "testValue"; + field.type = SdkFieldType.Text; + cipherView.fields = [field]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.collectionIds).toEqual([asUuid("b0473506-3c3c-4260-a734-dfaaf833ab6f")]); + expect(result.name).toBe("Test Login"); + expect(result.notes).toBe("Test notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + type: SdkFieldType.Text, + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("handles undefined organizationId and folderId", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.name).toBe("Test Cipher"); + }); + + it("handles empty collectionIds array", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.collectionIds = []; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.collectionIds).toEqual([]); + }); + + it("defaults favorite to false when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.favorite = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.favorite).toBe(false); + }); + + it("defaults reprompt to None when undefined", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + cipherView.reprompt = undefined as any; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const result = cipherView.toSdkCreateCipherRequest(); + + expect(result.reprompt).toBe(CipherRepromptType.None); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkCreateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("toSdkUpdateCipherRequest", () => { + it("maps all properties correctly for an update request", () => { + const { FieldView: RealFieldView } = jest.requireActual("./field.view"); + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.organizationId = "000f2a6e-da5e-4726-87ed-1c5c77322c3c"; + cipherView.folderId = "41b22db4-8e2a-4ed2-b568-f1186c72922f"; + cipherView.name = "Updated Login"; + cipherView.notes = "Updated notes"; + cipherView.type = CipherType.Login; + cipherView.favorite = true; + cipherView.reprompt = CipherRepromptType.Password; + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + cipherView.archivedDate = new Date("2022-01-03T12:00:00.000Z"); + cipherView.key = new EncString("cipher-key"); + + const mockField = new RealFieldView(); + mockField.name = "testField"; + mockField.value = "testValue"; + cipherView.fields = [mockField]; + + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.id).toEqual(asUuid("0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602")); + expect(result.organizationId).toEqual(asUuid("000f2a6e-da5e-4726-87ed-1c5c77322c3c")); + expect(result.folderId).toEqual(asUuid("41b22db4-8e2a-4ed2-b568-f1186c72922f")); + expect(result.name).toBe("Updated Login"); + expect(result.notes).toBe("Updated notes"); + expect(result.favorite).toBe(true); + expect(result.reprompt).toBe(CipherRepromptType.Password); + expect(result.revisionDate).toBe("2022-01-02T12:00:00.000Z"); + expect(result.archivedDate).toBe("2022-01-03T12:00:00.000Z"); + expect(result.fields).toHaveLength(1); + expect(result.fields![0]).toMatchObject({ + name: "testField", + value: "testValue", + }); + expect(result.type).toHaveProperty("login"); + expect((result.type as any).login).toMatchObject({ + username: "testuser", + }); + expect(result.key).toBeDefined(); + }); + + it("handles undefined optional properties", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + cipherView.revisionDate = new Date("2022-01-02T12:00:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.organizationId).toBeUndefined(); + expect(result.folderId).toBeUndefined(); + expect(result.archivedDate).toBeUndefined(); + expect(result.key).toBeUndefined(); + }); + + it("converts dates to ISO strings", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.revisionDate = new Date("2022-05-15T10:30:00.000Z"); + cipherView.archivedDate = new Date("2022-06-20T14:45:00.000Z"); + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.revisionDate).toBe("2022-05-15T10:30:00.000Z"); + expect(result.archivedDate).toBe("2022-06-20T14:45:00.000Z"); + }); + + it("includes attachments when present", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + const { AttachmentView: RealAttachmentView } = jest.requireActual("./attachment.view"); + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = "Test Cipher"; + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + + const attachment1 = new RealAttachmentView(); + attachment1.id = "attachment-id-1"; + attachment1.fileName = "file1.txt"; + + const attachment2 = new RealAttachmentView(); + attachment2.id = "attachment-id-2"; + attachment2.fileName = "file2.pdf"; + + cipherView.attachments = [attachment1, attachment2]; + + const result = cipherView.toSdkUpdateCipherRequest(); + + expect(result.attachments).toHaveLength(2); + }); + + test.each([ + ["Login", CipherType.Login, "login.view", "LoginView"], + ["Card", CipherType.Card, "card.view", "CardView"], + ["Identity", CipherType.Identity, "identity.view", "IdentityView"], + ["SecureNote", CipherType.SecureNote, "secure-note.view", "SecureNoteView"], + ["SshKey", CipherType.SshKey, "ssh-key.view", "SshKeyView"], + ])( + "creates correct type property for %s cipher", + (typeName: string, cipherType: CipherType, moduleName: string, className: string) => { + const module = jest.requireActual(`./${moduleName}`); + const ViewClass = module[className]; + + const cipherView = new CipherView(); + cipherView.id = "0a54d80c-14aa-4ef8-8c3a-7ea99ce5b602"; + cipherView.name = `Test ${typeName}`; + cipherView.type = cipherType; + + // Set the appropriate view property + const viewPropertyName = typeName.charAt(0).toLowerCase() + typeName.slice(1); + (cipherView as any)[viewPropertyName] = new ViewClass(); + + const result = cipherView.toSdkUpdateCipherRequest(); + + const typeKey = typeName.charAt(0).toLowerCase() + typeName.slice(1); + expect(result.type).toHaveProperty(typeKey); + }, + ); + }); + + describe("getSdkCipherViewType", () => { + it("returns login type for Login cipher", () => { + const { LoginView: RealLoginView } = jest.requireActual("./login.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Login; + cipherView.login = new RealLoginView(); + cipherView.login.username = "testuser"; + cipherView.login.password = "testpass"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + expect((result as any).login).toMatchObject({ + username: "testuser", + password: "testpass", + }); + }); + + it("returns card type for Card cipher", () => { + const { CardView: RealCardView } = jest.requireActual("./card.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Card; + cipherView.card = new RealCardView(); + cipherView.card.cardholderName = "John Doe"; + cipherView.card.number = "4111111111111111"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("card"); + expect((result as any).card.cardholderName).toBe("John Doe"); + expect((result as any).card.number).toBe("4111111111111111"); + }); + + it("returns identity type for Identity cipher", () => { + const { IdentityView: RealIdentityView } = jest.requireActual("./identity.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.Identity; + cipherView.identity = new RealIdentityView(); + cipherView.identity.firstName = "John"; + cipherView.identity.lastName = "Doe"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("identity"); + expect((result as any).identity.firstName).toBe("John"); + expect((result as any).identity.lastName).toBe("Doe"); + }); + + it("returns secureNote type for SecureNote cipher", () => { + const { SecureNoteView: RealSecureNoteView } = jest.requireActual("./secure-note.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SecureNote; + cipherView.secureNote = new RealSecureNoteView(); + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("secureNote"); + }); + + it("returns sshKey type for SshKey cipher", () => { + const { SshKeyView: RealSshKeyView } = jest.requireActual("./ssh-key.view"); + + const cipherView = new CipherView(); + cipherView.type = CipherType.SshKey; + cipherView.sshKey = new RealSshKeyView(); + cipherView.sshKey.privateKey = "privateKeyData"; + cipherView.sshKey.publicKey = "publicKeyData"; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("sshKey"); + expect((result as any).sshKey.privateKey).toBe("privateKeyData"); + expect((result as any).sshKey.publicKey).toBe("publicKeyData"); + }); + + it("defaults to empty login for unknown cipher type", () => { + const cipherView = new CipherView(); + cipherView.type = 999 as CipherType; + + const result = cipherView.getSdkCipherViewType(); + + expect(result).toHaveProperty("login"); + }); + }); }); diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 89f59665681..0909d0bda80 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,7 +1,12 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { asUuid, uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { ItemView } from "@bitwarden/common/vault/models/view/item.view"; -import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; +import { + CipherCreateRequest, + CipherEditRequest, + CipherViewType, + CipherView as SdkCipherView, +} from "@bitwarden/sdk-internal"; import { View } from "../../../models/view/view"; import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; @@ -332,6 +337,85 @@ export class CipherView implements View, InitializerMetadata { return cipherView; } + /** + * Maps CipherView to an SDK CipherCreateRequest + * + * @returns {CipherCreateRequest} The SDK cipher create request object + */ + toSdkCreateCipherRequest(): CipherCreateRequest { + const sdkCipherCreateRequest: CipherCreateRequest = { + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + collectionIds: this.collectionIds ? this.collectionIds.map((i) => asUuid(i)) : [], + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + favorite: this.favorite ?? false, + reprompt: this.reprompt ?? CipherRepromptType.None, + fields: this.fields?.map((f) => f.toSdkFieldView()), + type: this.getSdkCipherViewType(), + }; + + return sdkCipherCreateRequest; + } + + /** + * Maps CipherView to an SDK CipherEditRequest + * + * @returns {CipherEditRequest} The SDK cipher edit request object + */ + toSdkUpdateCipherRequest(): CipherEditRequest { + const sdkCipherEditRequest: CipherEditRequest = { + id: asUuid(this.id), + organizationId: this.organizationId ? asUuid(this.organizationId) : undefined, + folderId: this.folderId ? asUuid(this.folderId) : undefined, + name: this.name ?? "", + notes: this.notes, + favorite: this.favorite ?? false, + reprompt: this.reprompt ?? CipherRepromptType.None, + fields: this.fields?.map((f) => f.toSdkFieldView()), + type: this.getSdkCipherViewType(), + revisionDate: this.revisionDate?.toISOString(), + archivedDate: this.archivedDate?.toISOString(), + attachments: this.attachments?.map((a) => a.toSdkAttachmentView()), + key: this.key?.toSdk(), + }; + + return sdkCipherEditRequest; + } + + /** + * Returns the SDK CipherViewType object for the cipher. + * + * @returns {CipherViewType} The SDK CipherViewType for the cipher.t + */ + getSdkCipherViewType(): CipherViewType { + let viewType: CipherViewType; + switch (this.type) { + case CipherType.Card: + viewType = { card: this.card?.toSdkCardView() }; + break; + case CipherType.Identity: + viewType = { identity: this.identity?.toSdkIdentityView() }; + break; + case CipherType.Login: + viewType = { login: this.login?.toSdkLoginView() }; + break; + case CipherType.SecureNote: + viewType = { secureNote: this.secureNote?.toSdkSecureNoteView() }; + break; + case CipherType.SshKey: + viewType = { sshKey: this.sshKey?.toSdkSshKeyView() }; + break; + default: + viewType = { + // Default to empty login - should not be valid code path. + login: new LoginView().toSdkLoginView(), + }; + break; + } + return viewType; + } + /** * Maps CipherView to SdkCipherView * diff --git a/libs/common/src/vault/services/cipher-sdk.service.spec.ts b/libs/common/src/vault/services/cipher-sdk.service.spec.ts new file mode 100644 index 00000000000..bd3feb4619e --- /dev/null +++ b/libs/common/src/vault/services/cipher-sdk.service.spec.ts @@ -0,0 +1,246 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { UserId, CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { CipherType } from "../enums/cipher-type"; + +import { DefaultCipherSdkService } from "./cipher-sdk.service"; + +describe("DefaultCipherSdkService", () => { + const sdkService = mock(); + const logService = mock(); + const userId = "test-user-id" as UserId; + const cipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId; + + let cipherSdkService: DefaultCipherSdkService; + let mockSdkClient: any; + let mockCiphersSdk: any; + let mockAdminSdk: any; + let mockVaultSdk: any; + + beforeEach(() => { + // Mock the SDK client chain for admin operations + mockAdminSdk = { + create: jest.fn(), + edit: jest.fn(), + }; + mockCiphersSdk = { + create: jest.fn(), + edit: jest.fn(), + admin: jest.fn().mockReturnValue(mockAdminSdk), + }; + mockVaultSdk = { + ciphers: jest.fn().mockReturnValue(mockCiphersSdk), + }; + const mockSdkValue = { + vault: jest.fn().mockReturnValue(mockVaultSdk), + }; + mockSdkClient = { + take: jest.fn().mockReturnValue({ + value: mockSdkValue, + [Symbol.dispose]: jest.fn(), + }), + }; + + // Mock sdkService to return the mock client + sdkService.userClient$.mockReturnValue(of(mockSdkClient)); + + cipherSdkService = new DefaultCipherSdkService(sdkService, logService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("createWithServer()", () => { + it("should create cipher using SDK when orgAdmin is false", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Test Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockCiphersSdk.create.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.createWithServer(cipherView, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: cipherView.name, + organizationId: expect.anything(), + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result?.name).toBe(cipherView.name); + }); + + it("should create cipher using SDK admin API when orgAdmin is true", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Test Admin Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.create.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.createWithServer(cipherView, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: cipherView.name, + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result?.name).toBe(cipherView.name); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to create cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + mockCiphersSdk.create.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.createWithServer(cipherView, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to create cipher"), + ); + }); + }); + + describe("updateWithServer()", () => { + it("should update cipher using SDK when orgAdmin is false", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockCiphersSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should update cipher using SDK admin API when orgAdmin is true", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Admin Cipher"; + cipherView.organizationId = orgId; + + const originalCipherView = new CipherView(); + originalCipherView.id = cipherId; + originalCipherView.name = "Original Cipher"; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer( + cipherView, + userId, + originalCipherView, + true, + ); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + originalCipherView.toSdkCipherView(), + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should update cipher using SDK admin API without originalCipherView", async () => { + const cipherView = new CipherView(); + cipherView.id = cipherId; + cipherView.type = CipherType.Login; + cipherView.name = "Updated Admin Cipher"; + cipherView.organizationId = orgId; + + const mockSdkCipherView = cipherView.toSdkCipherView(); + mockAdminSdk.edit.mockResolvedValue(mockSdkCipherView); + + const result = await cipherSdkService.updateWithServer(cipherView, userId, undefined, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.edit).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.anything(), + name: cipherView.name, + }), + expect.anything(), // Empty CipherView - timestamps vary so we just verify it was called + ); + expect(result).toBeInstanceOf(CipherView); + expect(result.name).toBe(cipherView.name); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + await expect( + cipherSdkService.updateWithServer(cipherView, userId, undefined, false), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to update cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + const cipherView = new CipherView(); + cipherView.name = "Test Cipher"; + + mockCiphersSdk.edit.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.updateWithServer(cipherView, userId, undefined, false), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to update cipher"), + ); + }); + }); +}); diff --git a/libs/common/src/vault/services/cipher-sdk.service.ts b/libs/common/src/vault/services/cipher-sdk.service.ts new file mode 100644 index 00000000000..06f5d3eb961 --- /dev/null +++ b/libs/common/src/vault/services/cipher-sdk.service.ts @@ -0,0 +1,82 @@ +import { firstValueFrom, switchMap, catchError } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; + +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; + +export class DefaultCipherSdkService implements CipherSdkService { + constructor( + private sdkService: SdkService, + private logService: LogService, + ) {} + + async createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sdkCreateRequest = cipherView.toSdkCreateCipherRequest(); + let result: SdkCipherView; + if (orgAdmin) { + result = await ref.value.vault().ciphers().admin().create(sdkCreateRequest); + } else { + result = await ref.value.vault().ciphers().create(sdkCreateRequest); + } + return CipherView.fromSdkCipherView(result); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to create cipher: ${error}`); + throw error; + }), + ), + ); + } + + async updateWithServer( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + const sdkUpdateRequest = cipher.toSdkUpdateCipherRequest(); + let result: SdkCipherView; + if (orgAdmin) { + result = await ref.value + .vault() + .ciphers() + .admin() + .edit( + sdkUpdateRequest, + originalCipherView?.toSdkCipherView() || new CipherView().toSdkCipherView(), + ); + } else { + result = await ref.value.vault().ciphers().edit(sdkUpdateRequest); + } + return CipherView.fromSdkCipherView(result); + }), + catchError((error: unknown) => { + this.logService.error(`Failed to update cipher: ${error}`); + throw error; + }), + ), + ); + } +} diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 153bb01403c..4f98ba62a1c 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -28,6 +28,7 @@ import { ContainerService } from "../../platform/services/container.service"; import { CipherId, UserId, OrganizationId, CollectionId } from "../../types/guid"; import { CipherKey, OrgKey, UserKey } from "../../types/key"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; import { EncryptionContext } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { SearchService } from "../abstractions/search.service"; @@ -54,9 +55,9 @@ function encryptText(clearText: string | Uint8Array) { const ENCRYPTED_BYTES = mock(); const cipherData: CipherData = { - id: "id", - organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId, - folderId: "folderId", + id: "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + organizationId: "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId, + folderId: "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23", edit: true, viewPassword: true, organizationUseTotp: true, @@ -109,9 +110,10 @@ describe("Cipher Service", () => { const stateProvider = new FakeStateProvider(accountService); const cipherEncryptionService = mock(); const messageSender = mock(); + const cipherSdkService = mock(); const userId = "TestUserId" as UserId; - const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b2" as OrganizationId; + const orgId = "4ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b21" as OrganizationId; let cipherService: CipherService; let encryptionContext: EncryptionContext; @@ -145,6 +147,7 @@ describe("Cipher Service", () => { logService, cipherEncryptionService, messageSender, + cipherSdkService, ); encryptionContext = { cipher: new Cipher(cipherData), encryptedFor: userId }; @@ -207,11 +210,22 @@ describe("Cipher Service", () => { }); describe("createWithServer()", () => { + beforeEach(() => { + jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext); + jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => { + return new CipherView(cipher); + }); + }); + it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); const spy = jest .spyOn(apiService, "postCipherAdmin") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext, true); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId, true); const expectedObj = new CipherCreateRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -219,11 +233,15 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.organizationId = null!; const spy = jest .spyOn(apiService, "postCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext, true); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId, true); const expectedObj = new CipherRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -231,11 +249,15 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipherCreate if collectionsIds != null", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.collectionIds = ["123"]; const spy = jest .spyOn(apiService, "postCipherCreate") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId); const expectedObj = new CipherCreateRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -243,35 +265,86 @@ describe("Cipher Service", () => { }); it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); const spy = jest .spyOn(apiService, "postCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.createWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.createWithServer(cipherView, userId); const expectedObj = new CipherRequest(encryptionContext); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(expectedObj); }); + + it("should delegate to cipherSdkService when feature flag is enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(true); + + const cipherView = new CipherView(encryptionContext.cipher); + const expectedResult = new CipherView(encryptionContext.cipher); + + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "createWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "postCipher"); + + const result = await cipherService.createWithServer(cipherView, userId); + + expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + expect(result).toBeInstanceOf(CipherView); + }); }); describe("updateWithServer()", () => { + beforeEach(() => { + jest.spyOn(cipherService, "encrypt").mockResolvedValue(encryptionContext); + jest.spyOn(cipherService, "decrypt").mockImplementation(async (cipher) => { + return new CipherView(cipher); + }); + jest.spyOn(cipherService, "upsert").mockResolvedValue({ + [cipherData.id as CipherId]: cipherData, + }); + }); + it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); + + const testCipher = new Cipher(cipherData); + testCipher.organizationId = orgId; + const testContext = { cipher: testCipher, encryptedFor: userId }; + jest.spyOn(cipherService, "encrypt").mockResolvedValue(testContext); + const spy = jest .spyOn(apiService, "putCipherAdmin") - .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.updateWithServer(encryptionContext, true); - const expectedObj = new CipherRequest(encryptionContext); + .mockImplementation(() => Promise.resolve(testCipher.toCipherData())); + const cipherView = new CipherView(testCipher); + await cipherService.updateWithServer(cipherView, userId, undefined, true); + const expectedObj = new CipherRequest(testContext); expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj); + expect(spy).toHaveBeenCalledWith(testCipher.id, expectedObj); }); it("should call apiService.putCipher if cipher.edit is true", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.edit = true; const spy = jest .spyOn(apiService, "putCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.updateWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.updateWithServer(cipherView, userId); const expectedObj = new CipherRequest(encryptionContext); expect(spy).toHaveBeenCalled(); @@ -279,16 +352,79 @@ describe("Cipher Service", () => { }); it("should call apiService.putPartialCipher when orgAdmin, and edit are false", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(false); encryptionContext.cipher.edit = false; const spy = jest .spyOn(apiService, "putPartialCipher") .mockImplementation(() => Promise.resolve(encryptionContext.cipher.toCipherData())); - await cipherService.updateWithServer(encryptionContext); + const cipherView = new CipherView(encryptionContext.cipher); + await cipherService.updateWithServer(cipherView, userId); const expectedObj = new CipherPartialRequest(encryptionContext.cipher); expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith(encryptionContext.cipher.id, expectedObj); }); + + it("should delegate to cipherSdkService when feature flag is enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(true); + + const testCipher = new Cipher(cipherData); + const cipherView = new CipherView(testCipher); + const expectedResult = new CipherView(testCipher); + + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "updateWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "putCipher"); + + const result = await cipherService.updateWithServer(cipherView, userId); + + expect(cipherSdkServiceSpy).toHaveBeenCalledWith(cipherView, userId, undefined, undefined); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + expect(result).toBeInstanceOf(CipherView); + }); + + it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => { + configService.getFeatureFlag + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockResolvedValue(true); + + const testCipher = new Cipher(cipherData); + const cipherView = new CipherView(testCipher); + const originalCipherView = new CipherView(testCipher); + const expectedResult = new CipherView(testCipher); + + const cipherSdkServiceSpy = jest + .spyOn(cipherSdkService, "updateWithServer") + .mockResolvedValue(expectedResult); + + const clearCacheSpy = jest.spyOn(cipherService, "clearCache"); + const apiSpy = jest.spyOn(apiService, "putCipherAdmin"); + + const result = await cipherService.updateWithServer( + cipherView, + userId, + originalCipherView, + true, + ); + + expect(cipherSdkServiceSpy).toHaveBeenCalledWith( + cipherView, + userId, + originalCipherView, + true, + ); + expect(apiSpy).not.toHaveBeenCalled(); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + expect(result).toBeInstanceOf(CipherView); + }); }); describe("encrypt", () => { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 2e0adc892e3..53d7666e304 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -42,6 +42,7 @@ import { CipherId, CollectionId, OrganizationId, UserId } from "../../types/guid import { OrgKey, UserKey } from "../../types/key"; import { filterOutNullish, perUserCache$ } from "../../vault/utils/observable-utilities"; import { CipherEncryptionService } from "../abstractions/cipher-encryption.service"; +import { CipherSdkService } from "../abstractions/cipher-sdk.service"; import { CipherService as CipherServiceAbstraction, EncryptionContext, @@ -120,6 +121,7 @@ export class CipherService implements CipherServiceAbstraction { private logService: LogService, private cipherEncryptionService: CipherEncryptionService, private messageSender: MessageSender, + private cipherSdkService: CipherSdkService, ) {} localData$(userId: UserId): Observable> { @@ -903,6 +905,40 @@ export class CipherService implements CipherServiceAbstraction { } async createWithServer( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + const useSdk = await this.configService.getFeatureFlag( + FeatureFlag.PM27632_SdkCipherCrudOperations, + ); + + if (useSdk) { + return ( + (await this.createWithServerUsingSdk(cipherView, userId, orgAdmin)) || new CipherView() + ); + } + + const encrypted = await this.encrypt(cipherView, userId); + const result = await this.createWithServer_legacy(encrypted, orgAdmin); + return await this.decrypt(result, userId); + } + + private async createWithServerUsingSdk( + cipherView: CipherView, + userId: UserId, + orgAdmin?: boolean, + ): Promise { + const resultCipherView = await this.cipherSdkService.createWithServer( + cipherView, + userId, + orgAdmin, + ); + await this.clearCache(userId); + return resultCipherView; + } + + private async createWithServer_legacy( { cipher, encryptedFor }: EncryptionContext, orgAdmin?: boolean, ): Promise { @@ -929,6 +965,42 @@ export class CipherService implements CipherServiceAbstraction { } async updateWithServer( + cipherView: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + const useSdk = await this.configService.getFeatureFlag( + FeatureFlag.PM27632_SdkCipherCrudOperations, + ); + + if (useSdk) { + return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin); + } + + const encrypted = await this.encrypt(cipherView, userId); + const updatedCipher = await this.updateWithServer_legacy(encrypted, orgAdmin); + const updatedCipherView = await this.decrypt(updatedCipher, userId); + return updatedCipherView; + } + + async updateWithServerUsingSdk( + cipher: CipherView, + userId: UserId, + originalCipherView?: CipherView, + orgAdmin?: boolean, + ): Promise { + const resultCipherView = await this.cipherSdkService.updateWithServer( + cipher, + userId, + originalCipherView, + orgAdmin, + ); + await this.clearCache(userId); + return resultCipherView; + } + + async updateWithServer_legacy( { cipher, encryptedFor }: EncryptionContext, orgAdmin?: boolean, ): Promise { @@ -1119,8 +1191,7 @@ export class CipherService implements CipherServiceAbstraction { //in order to keep item and it's attachments with the same encryption level if (cipher.key != null && !cipherKeyEncryptionEnabled) { const model = await this.decrypt(cipher, userId); - const reEncrypted = await this.encrypt(model, userId); - await this.updateWithServer(reEncrypted); + await this.updateWithServer(model, userId); } const encFileName = await this.encryptService.encryptString(filename, cipherEncKey); diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 59c583f980b..8566e51d74f 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -37,14 +37,13 @@ export class DefaultCipherFormService implements CipherFormService { // Creating a new cipher if (cipher.id == null || cipher.id === "") { - const encrypted = await this.cipherService.encrypt(cipher, activeUserId); - savedCipher = await this.cipherService.createWithServer(encrypted, config.admin); - return await this.cipherService.decrypt(savedCipher, activeUserId); + return await this.cipherService.createWithServer(cipher, activeUserId, config.admin); } if (config.originalCipher == null) { throw new Error("Original cipher is required for updating an existing cipher"); } + const originalCipherView = await this.decryptCipher(config.originalCipher); // Updating an existing cipher @@ -66,35 +65,31 @@ export class DefaultCipherFormService implements CipherFormService { ); // If the collectionIds are the same, update the cipher normally } else if (isSetEqual(originalCollectionIds, newCollectionIds)) { - const encrypted = await this.cipherService.encrypt( + const savedCipherView = await this.cipherService.updateWithServer( cipher, activeUserId, - null, - null, - config.originalCipher, + originalCipherView, + config.admin, ); - savedCipher = await this.cipherService.updateWithServer(encrypted, config.admin); + savedCipher = await this.cipherService + .encrypt(savedCipherView, activeUserId) + .then((res) => res.cipher); } else { - const encrypted = await this.cipherService.encrypt( - cipher, - activeUserId, - null, - null, - config.originalCipher, - ); - const encryptedCipher = encrypted.cipher; - // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds - encryptedCipher.collectionIds = config.originalCipher.collectionIds; - await this.cipherService.updateWithServer( - encrypted, + cipher.collectionIds = config.originalCipher.collectionIds; + const newCipher = await this.cipherService.updateWithServer( + cipher, + activeUserId, + originalCipherView, config.admin || originalCollectionIds.size === 0, ); // Then save the new collection changes separately - encryptedCipher.collectionIds = cipher.collectionIds; + newCipher.collectionIds = cipher.collectionIds; + // TODO: Remove after migrating all SDK ops + const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId); if (config.admin || originalCollectionIds.size === 0) { // When using an admin config or the cipher was unassigned, update collections as an admin savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher); From 8b9211ea620f5cfcbe908fed31e390ae06268d1e Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:52:30 -0800 Subject: [PATCH 46/52] do not show badge/button in AC (#18489) --- .../reports/pages/cipher-report.component.ts | 1 + .../vault-item-dialog.component.html | 2 +- .../vault-item-dialog.component.spec.ts | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts index d8519b86094..f775ed84ede 100644 --- a/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/cipher-report.component.ts @@ -193,6 +193,7 @@ export abstract class CipherReportComponent implements OnDestroy { formConfig, activeCollectionId, disableForm, + isAdminConsoleAction: true, }); const result = await lastValueFrom(this.vaultItemDialogRef.closed); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 059347709f0..ec06c740f24 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -3,7 +3,7 @@ {{ title }} - @if (isCipherArchived) { + @if (isCipherArchived && !params.isAdminConsoleAction) { {{ "archived" | i18n }} } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index 63b5071d1f5..9a048b7a8b3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -303,6 +303,25 @@ describe("VaultItemDialogComponent", () => { }); }); + describe("archive badge", () => { + it('should show "archived" badge when the item is archived and not an admin console action', () => { + component.setTestCipher({ isArchived: true }); + component.setTestParams({ mode: "view" }); + fixture.detectChanges(); + const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]")); + expect(archivedBadge).toBeTruthy(); + expect(archivedBadge.nativeElement.textContent.trim()).toBe("archived"); + }); + + it('should not show "archived" badge when the item is archived and is an admin console action', () => { + component.setTestCipher({ isArchived: true }); + component.setTestParams({ mode: "view", isAdminConsoleAction: true }); + fixture.detectChanges(); + const archivedBadge = fixture.debugElement.query(By.css("span[bitBadge]")); + expect(archivedBadge).toBeFalsy(); + }); + }); + describe("submitButtonText$", () => { it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => { jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); From 5e8801f7ff5a71a91d7455088b387aae103c7b17 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:00:03 -0800 Subject: [PATCH 47/52] [PM-29244] - don't use filename for download attachment label (#18444) * don't use filename for download attachment label * fix scroll position in browser vault * Revert "fix scroll position in browser vault" This reverts commit 8e415f2c899c3d2b6b029e1b013f85dc131b3468. * fix test --- apps/browser/src/_locales/en/messages.json | 3 +++ .../download-attachment/download-attachment.component.html | 2 +- .../download-attachment/download-attachment.component.spec.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 61085828cf2..8e2c3279687 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5001,6 +5001,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.html b/libs/vault/src/components/download-attachment/download-attachment.component.html index 9d80f36818a..c6665c5d569 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.html +++ b/libs/vault/src/components/download-attachment/download-attachment.component.html @@ -5,6 +5,6 @@ buttonType="main" size="small" type="button" - [label]="'downloadAttachmentName' | i18n: attachment().fileName" + [label]="'downloadAttachmentLabel' | i18n" > } diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index 3bbc375fdfc..a46ce28fca8 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentLabel"); }); describe("download attachment", () => { From ad577860be3f9f43836b56017b4985232eca7aca Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:01:53 -0600 Subject: [PATCH 48/52] [PM-28060] Remove Skeleton Feature Flag (#18456) * remove skeleton ff * remove unneeded templates --- .../popup/send-v2/send-v2.component.html | 2 +- .../popup/send-v2/send-v2.component.spec.ts | 2 - .../tools/popup/send-v2/send-v2.component.ts | 18 +-- .../vault-v2-search.component.spec.ts | 123 ++++++------------ .../vault-search/vault-v2-search.component.ts | 39 ++---- .../vault-v2/vault-v2.component.html | 95 ++++++-------- .../vault-v2/vault-v2.component.spec.ts | 2 + .../components/vault-v2/vault-v2.component.ts | 14 +- libs/common/src/enums/feature-flag.enum.ts | 2 - 9 files changed, 96 insertions(+), 201 deletions(-) diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 47ecd7564dc..48295fda35d 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index dfbfabf8d5e..dc4b935c6c8 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -11,7 +11,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -110,7 +109,6 @@ describe("SendV2Component", () => { provide: BillingAccountProfileStateService, useValue: { hasPremiumFromAnySource$: of(false) }, }, - { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index f36a475a805..8c1edee79dc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -11,8 +11,6 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; @@ -84,30 +82,17 @@ export class SendV2Component implements OnDestroy { protected listState: SendState | null = null; protected sends$ = this.sendItemsService.filteredAndSortedSends$; - private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); protected sendsLoading$ = this.sendItemsService.loading$.pipe( distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: true }), ); - /** Spinner Loading State */ - protected showSpinnerLoaders$ = combineLatest([ - this.sendsLoading$, - this.skeletonFeatureFlag$, - ]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled)); - /** Skeleton Loading State */ protected showSkeletonsLoaders$ = combineLatest([ this.sendsLoading$, this.searchService.isSendSearching$, - this.skeletonFeatureFlag$, ]).pipe( - map( - ([loading, cipherSearching, skeletonsEnabled]) => - (loading || cipherSearching) && skeletonsEnabled, - ), + map(([loading, cipherSearching]) => loading || cipherSearching), distinctUntilChanged(), skeletonLoadingDelay(), ); @@ -128,7 +113,6 @@ export class SendV2Component implements OnDestroy { protected sendListFiltersService: SendListFiltersService, private policyService: PolicyService, private accountService: AccountService, - private configService: ConfigService, private searchService: SearchService, ) { combineLatest([ diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts index 37c4804e600..ca73a7332ee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts @@ -4,7 +4,6 @@ import { FormsModule } from "@angular/forms"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -20,7 +19,6 @@ describe("VaultV2SearchComponent", () => { const searchText$ = new BehaviorSubject(""); const loading$ = new BehaviorSubject(false); - const featureFlag$ = new BehaviorSubject(true); const applyFilter = jest.fn(); const createComponent = () => { @@ -31,7 +29,6 @@ describe("VaultV2SearchComponent", () => { beforeEach(async () => { applyFilter.mockClear(); - featureFlag$.next(true); await TestBed.configureTestingModule({ imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], @@ -49,12 +46,6 @@ describe("VaultV2SearchComponent", () => { loading$, }, }, - { - provide: ConfigService, - useValue: { - getFeatureFlag$: jest.fn(() => featureFlag$), - }, - }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -70,91 +61,55 @@ describe("VaultV2SearchComponent", () => { }); describe("debouncing behavior", () => { - describe("when feature flag is enabled", () => { - beforeEach(() => { - featureFlag$.next(true); - createComponent(); - }); - - it("debounces search text changes when not loading", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("should not debounce search text changes when loading", fakeAsync(() => { - loading$.next(true); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(0); - - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - - it("cancels previous debounce when new text is entered", fakeAsync(() => { - loading$.next(false); - - component.searchText = "test"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - component.searchText = "test2"; - component.onSearchTextChanged(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).not.toHaveBeenCalled(); - - tick(SearchTextDebounceInterval / 2); - - expect(applyFilter).toHaveBeenCalledWith("test2"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + beforeEach(() => { + createComponent(); }); - describe("when feature flag is disabled", () => { - beforeEach(() => { - featureFlag$.next(false); - createComponent(); - }); + it("debounces search text changes when not loading", fakeAsync(() => { + loading$.next(false); - it("debounces search text changes", fakeAsync(() => { - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + expect(applyFilter).not.toHaveBeenCalled(); - tick(SearchTextDebounceInterval); + tick(SearchTextDebounceInterval); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - it("ignores loading state and always debounces", fakeAsync(() => { - loading$.next(true); + it("should not debounce search text changes when loading", fakeAsync(() => { + loading$.next(true); - component.searchText = "test"; - component.onSearchTextChanged(); + component.searchText = "test"; + component.onSearchTextChanged(); - expect(applyFilter).not.toHaveBeenCalled(); + tick(0); - tick(SearchTextDebounceInterval); + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); - expect(applyFilter).toHaveBeenCalledWith("test"); - expect(applyFilter).toHaveBeenCalledTimes(1); - })); - }); + it("cancels previous debounce when new text is entered", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + component.searchText = "test2"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).toHaveBeenCalledWith("test2"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index 154cd49c5a3..3419bd30ea0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -7,17 +7,13 @@ import { Subscription, combineLatest, debounce, - debounceTime, distinctUntilChanged, filter, map, - switchMap, timer, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -40,7 +36,6 @@ export class VaultV2SearchComponent { constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupLoadingService: VaultPopupLoadingService, - private configService: ConfigService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); @@ -63,31 +58,19 @@ export class VaultV2SearchComponent { } subscribeToApplyFilter(): void { - this.configService - .getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons) + combineLatest([this.searchText$, this.loading$]) .pipe( - switchMap((enabled) => { - if (!enabled) { - return this.searchText$.pipe( - debounceTime(SearchTextDebounceInterval), - distinctUntilChanged(), - ); - } - - return combineLatest([this.searchText$, this.loading$]).pipe( - debounce(([_, isLoading]) => { - // If loading apply immediately to avoid stale searches. - // After loading completes, debounce to avoid excessive searches. - const delayTime = isLoading ? 0 : SearchTextDebounceInterval; - return timer(delayTime); - }), - distinctUntilChanged( - ([prevText, prevLoading], [newText, newLoading]) => - prevText === newText && prevLoading === newLoading, - ), - map(([text, _]) => text), - ); + debounce(([_, isLoading]) => { + // If loading apply immediately to avoid stale searches. + // After loading completes, debounce to avoid excessive searches. + const delayTime = isLoading ? 0 : SearchTextDebounceInterval; + return timer(delayTime); }), + distinctUntilChanged( + ([prevText, prevLoading], [newText, newLoading]) => + prevText === newText && prevLoading === newLoading, + ), + map(([text, _]) => text), takeUntilDestroyed(), ) .subscribe((text) => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 34454371f21..20871b4b134 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -1,4 +1,4 @@ - + @@ -8,37 +8,28 @@ - -
- - {{ "yourVaultIsEmpty" | i18n }} - -

- {{ "emptyVaultDescription" | i18n }} -

-
- - {{ "newLogin" | i18n }} - -
-
-
- - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === VaultStateEnum.Empty) { + +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
- } @else { - } - - - - - - - - - @if (skeletonFeatureFlag$ | async) { - - + @if (vaultState === null) { + + @if (!(loading$ | async)) { + + + + } - } @else { - }
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 2c94d9c226b..e3b72c3319f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core"; import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { mock } from "jest-mock-extended"; @@ -243,6 +244,7 @@ describe("VaultV2Component", () => { await TestBed.configureTestingModule({ imports: [VaultV2Component, RouterTestingModule], providers: [ + provideNoopAnimations(), { provide: VaultPopupItemsService, useValue: itemsSvc }, { provide: VaultPopupListFiltersService, useValue: filtersSvc }, { provide: VaultPopupScrollPositionService, useValue: scrollSvc }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 4678e2733eb..c58b7b20d2f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -158,10 +158,6 @@ export class VaultV2Component implements OnInit, OnDestroy { }), ); - protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$( - FeatureFlag.VaultLoadingSkeletons, - ); - protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$( FeatureFlag.BrowserPremiumSpotlight, ); @@ -216,20 +212,14 @@ export class VaultV2Component implements OnInit, OnDestroy { PremiumUpgradeDialogComponent.open(this.dialogService); } - /** When true, show spinner loading state */ - protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( - map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled), - ); - /** When true, show skeleton loading state with debouncing to prevent flicker */ protected showSkeletonsLoaders$ = combineLatest([ this.loading$, this.searchService.isCipherSearching$, this.vaultItemsTransferService.transferInProgress$, - this.skeletonFeatureFlag$, ]).pipe( - map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => { - return (loading || cipherSearching || transferInProgress) && skeletonsEnabled; + map(([loading, cipherSearching, transferInProgress]) => { + return loading || cipherSearching || transferInProgress; }), distinctUntilChanged(), skeletonLoadingDelay(), diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 94656d48826..0086524a47f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -65,7 +65,6 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", - VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", @@ -129,7 +128,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, - [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE, [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, From 36b648f5d7ad62b5d3f40e1f72a724f7f85b9894 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:25:23 +0000 Subject: [PATCH 49/52] [deps]: Update taiki-e/install-action action to v2.66.7 (#18570) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81d79df569c..6a5f6774474 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -142,7 +142,7 @@ jobs: run: cargo +nightly udeps --workspace --all-features --all-targets - name: Install cargo-deny - uses: taiki-e/install-action@2e9d707ef49c9b094d45955b60c7e5c0dfedeb14 # v2.66.5 + uses: taiki-e/install-action@542cebaaed782771e619bd5609d97659d109c492 # v2.66.7 with: tool: cargo-deny@0.18.6 From e2fa296b042f3c433786a15a6c4a41909c194fc2 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:40:27 -0500 Subject: [PATCH 50/52] chore(deps): Added override for package-lock.json --- .github/CODEOWNERS | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d1266a174e4..3884bfda063 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -84,6 +84,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +libs/pricing @bitwarden/team-billing-dev bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## @@ -227,7 +228,9 @@ apps/web/src/locales/en/messages.json **/tsconfig.json @bitwarden/team-platform-dev **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev -libs/pricing @bitwarden/team-billing-dev +# Platform override specifically for the package-lock.json in +# native-messaging-test-runner so that Platform can manage all lock file updates +apps/desktop/native-messaging-test-runner/package-lock.json @bitwarden/team-platform-dev # Claude related files .claude/ @bitwarden/team-ai-sme From 60c28dd182eb7cbdd73956eb19509976c1c875b5 Mon Sep 17 00:00:00 2001 From: Leslie Tilton <23057410+Banrion@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:05:42 -0600 Subject: [PATCH 51/52] [PM-31203] Change Phishing Url Check to use a Cursor Based Search (#18561) * Initial changes to look at phishing indexeddb service and removal of obsolete compression code * Convert background update to rxjs format and trigger via subject. Update test cases * Added addUrls function to use instead of saveUrls so appending daily does not clear all urls * Added debug logs to phishing-indexeddb service * Added a fallback url when downloading phishing url list * Remove obsolete comments * Fix testUrl default, false scenario and test cases * Add default return on isPhishingWebAddress * Added log statement * Change hostname to href in hasUrl check * Save fallback response * Fix matching subpaths in links. Update test cases * Fix meta data updates storing last checked instead of last updated * Update QA phishing url to be normalized * Filter web addresses * Return previous meta to keep subscription alive * Change indexeddb lookup from loading all to cursor search * fix(phishing): improve performance and fix URL matching in phishing detection Problem: The cursor-based search takes ~25 seconds to scan the entire phishing database. For non-phishing URLs (99% of cases), this full scan runs to completion every time. Before these fixes, opening a new tab triggered this sequence: 1. chrome://newtab/ fires a phishing check 2. Sequential concatMap blocks while cursor scans all 500k+ URLs (~25 sec) 3. User pastes actual URL and hits enter 4. That URL's check waits in queue behind the chrome:// check 5. Total delay: ~50+ seconds for a simple "open tab, paste link" workflow Even for legitimate phishing checks, the cursor search could take up to 25 seconds per URL when the fast hasUrl lookup misses due to trailing slash mismatches. Changes: phishing-data.service.ts: - Add protocol filter to early-return for non-http(s) URLs, avoiding expensive IndexedDB operations for chrome://, about:, file:// URLs - Add trailing slash normalization for hasUrl lookup - browsers add trailing slashes but DB entries may not have them, causing O(1) lookups to miss and fall back to O(n) cursor search unnecessarily - Add debug logging for hasUrl checks and timing metrics for cursor-based search to aid performance debugging phishing-detection.service.ts: - Replace concatMap with mergeMap for parallel tab processing - each tab check now runs independently instead of sequentially - Add concurrency limit of 5 to prevent overwhelming IndexedDB while still allowing parallel execution Result: - New tabs are instant (no IndexedDB calls for non-web URLs) - One slow phishing check doesn't block other tabs - Common URL patterns hit the fast O(1) path instead of O(n) cursor scan * performance debug logs * disable custom match because too slow * spec fix --------- Co-authored-by: Alex --- .../phishing-detection/phishing-resources.ts | 4 + .../services/phishing-data.service.spec.ts | 42 ++++------ .../services/phishing-data.service.ts | 73 ++++++++++++++-- .../services/phishing-detection.service.ts | 54 ++++++++---- .../phishing-indexeddb.service.spec.ts | 83 +++++++++++++++++++ .../services/phishing-indexeddb.service.ts | 54 ++++++++++++ 6 files changed, 259 insertions(+), 51 deletions(-) diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 88068987dd7..6595104207a 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -7,6 +7,8 @@ export type PhishingResource = { todayUrl: string; /** Matcher used to decide whether a given URL matches an entry from this resource */ match: (url: URL, entry: string) => boolean; + /** Whether to use the custom matcher. If false, only exact hasUrl lookups are used. Default: true */ + useCustomMatcher?: boolean; }; export const PhishingResourceType = Object.freeze({ @@ -56,6 +58,8 @@ export const PHISHING_RESOURCES: Record { if (!entry) { return false; 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 d633c0612f5..2d6c7a5a651 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 @@ -40,6 +40,7 @@ describe("PhishingDataService", () => { // Set default mock behaviors mockIndexedDbService.hasUrl.mockResolvedValue(false); mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); mockIndexedDbService.saveUrls.mockResolvedValue(undefined); mockIndexedDbService.addUrls.mockResolvedValue(undefined); mockIndexedDbService.saveUrlsFromStream.mockResolvedValue(undefined); @@ -90,7 +91,7 @@ describe("PhishingDataService", () => { it("should NOT detect QA test addresses - different subpath", async () => { mockIndexedDbService.hasUrl.mockResolvedValue(false); - mockIndexedDbService.loadAllUrls.mockResolvedValue([]); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); const url = new URL("https://phishing.testcategory.com/other"); const result = await service.isPhishingWebAddress(url); @@ -120,70 +121,65 @@ describe("PhishingDataService", () => { expect(result).toBe(true); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/testing-param"); // Should not fall back to custom matcher when hasUrl returns true - expect(mockIndexedDbService.loadAllUrls).not.toHaveBeenCalled(); + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should fall back to custom matcher when hasUrl returns false", async () => { + it("should return false when hasUrl returns false (custom matcher disabled)", async () => { // Mock hasUrl to return false (no direct href match) mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return phishing URLs for custom matcher - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/path"]); const url = new URL("http://phish.com/path"); const result = await service.isPhishingWebAddress(url); - expect(result).toBe(true); + // Custom matcher is currently disabled (useCustomMatcher: false), so result is false + expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/path"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher should NOT be called since it's disabled + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); it("should not detect a safe web address", async () => { // Mock hasUrl to return false mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return phishing URLs that don't match - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com", "http://badguy.net"]); const url = new URL("http://safe.com"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://safe.com/"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should not match against root web address with subpaths using custom matcher", async () => { + it("should not match against root web address with subpaths (custom matcher disabled)", async () => { // Mock hasUrl to return false (no direct href match) mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return entry that matches with subpath - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login"]); const url = new URL("http://phish.com/login/page"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); - it("should not match against root web address with different subpaths using custom matcher", async () => { + it("should not match against root web address with different subpaths (custom matcher disabled)", async () => { // Mock hasUrl to return false (no direct hostname match) mockIndexedDbService.hasUrl.mockResolvedValue(false); - // Mock loadAllUrls to return entry that matches with subpath - mockIndexedDbService.loadAllUrls.mockResolvedValue(["http://phish.com/login/page1"]); const url = new URL("http://phish.com/login/page2"); const result = await service.isPhishingWebAddress(url); expect(result).toBe(false); expect(mockIndexedDbService.hasUrl).toHaveBeenCalledWith("http://phish.com/login/page2"); - expect(mockIndexedDbService.loadAllUrls).toHaveBeenCalled(); + // Custom matcher is disabled, so findMatchingUrl should NOT be called + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); it("should handle IndexedDB errors gracefully", async () => { // Mock hasUrl to throw error mockIndexedDbService.hasUrl.mockRejectedValue(new Error("hasUrl error")); - // Mock loadAllUrls to also throw error - mockIndexedDbService.loadAllUrls.mockRejectedValue(new Error("IndexedDB error")); const url = new URL("http://phish.com/about"); const result = await service.isPhishingWebAddress(url); @@ -193,10 +189,8 @@ describe("PhishingDataService", () => { "[PhishingDataService] IndexedDB lookup via hasUrl failed", expect.any(Error), ); - expect(logService.error).toHaveBeenCalledWith( - "[PhishingDataService] Error running custom matcher", - expect.any(Error), - ); + // Custom matcher is disabled, so no custom matcher error is expected + expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); }); 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 10268fa7f93..c34a94ecced 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 @@ -153,8 +153,18 @@ export class PhishingDataService { * @returns True if the URL is a known phishing web address, false otherwise */ async isPhishingWebAddress(url: URL): Promise { + this.logService.debug("[PhishingDataService] isPhishingWebAddress called for: " + url.href); + + // Skip non-http(s) protocols - phishing database only contains web URLs + // This prevents expensive fallback checks for chrome://, about:, file://, etc. + if (url.protocol !== "http:" && url.protocol !== "https:") { + this.logService.debug("[PhishingDataService] Skipping non-http(s) protocol: " + url.protocol); + return false; + } + // Quick check for QA/dev test addresses if (this._testWebAddresses.includes(url.href)) { + this.logService.info("[PhishingDataService] Found test web address: " + url.href); return true; } @@ -162,28 +172,73 @@ export class PhishingDataService { try { // Quick lookup: check direct presence of href in IndexedDB - const hasUrl = await this.indexedDbService.hasUrl(url.href); + // Also check without trailing slash since browsers add it but DB entries may not have it + const urlHref = url.href; + const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null; + + this.logService.debug("[PhishingDataService] Checking hasUrl on this string: " + urlHref); + let hasUrl = await this.indexedDbService.hasUrl(urlHref); + + // If not found and URL has trailing slash, try without it + if (!hasUrl && urlWithoutTrailingSlash) { + this.logService.debug( + "[PhishingDataService] Checking hasUrl without trailing slash: " + + urlWithoutTrailingSlash, + ); + hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash); + } + if (hasUrl) { + this.logService.info( + "[PhishingDataService] Found phishing web address through direct lookup: " + urlHref, + ); return true; } } catch (err) { this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err); } - // If a custom matcher is provided, iterate stored entries and apply the matcher. - if (resource && resource.match) { + // If a custom matcher is provided and enabled, use cursor-based search. + // This avoids loading all URLs into memory and allows early exit on first match. + // Can be disabled via useCustomMatcher: false for performance reasons. + if (resource && resource.match && resource.useCustomMatcher !== false) { try { - const entries = await this.indexedDbService.loadAllUrls(); - for (const entry of entries) { - if (resource.match(url, entry)) { - return true; - } + this.logService.debug( + "[PhishingDataService] Starting cursor-based search for: " + url.href, + ); + const startTime = performance.now(); + + const found = await this.indexedDbService.findMatchingUrl((entry) => + resource.match(url, entry), + ); + + const endTime = performance.now(); + const duration = (endTime - startTime).toFixed(2); + this.logService.debug( + `[PhishingDataService] Cursor-based search completed in ${duration}ms for: ${url.href} (found: ${found})`, + ); + + if (found) { + this.logService.info( + "[PhishingDataService] Found phishing web address through custom matcher: " + url.href, + ); + } else { + this.logService.debug( + "[PhishingDataService] No match found, returning false for: " + url.href, + ); } + return found; } catch (err) { this.logService.error("[PhishingDataService] Error running custom matcher", err); + this.logService.debug( + "[PhishingDataService] Returning false due to error for: " + url.href, + ); + return false; } - return false; } + this.logService.debug( + "[PhishingDataService] No custom matcher, returning false for: " + url.href, + ); return false; } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 815007e1d4c..6ca5bad8942 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,10 +1,10 @@ import { - concatMap, distinctUntilChanged, EMPTY, filter, map, merge, + mergeMap, Subject, switchMap, tap, @@ -43,6 +43,7 @@ export class PhishingDetectionService { private static _tabUpdated$ = new Subject(); private static _ignoredHostnames = new Set(); private static _didInit = false; + private static _activeSearchCount = 0; static initialize( logService: LogService, @@ -63,7 +64,7 @@ export class PhishingDetectionService { tap((message) => logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), ), - concatMap(async (message) => { + mergeMap(async (message) => { const url = new URL(message.url); this._ignoredHostnames.add(url.hostname); await BrowserApi.navigateTabToUrl(message.tabId, url); @@ -88,23 +89,40 @@ export class PhishingDetectionService { prev.ignored === curr.ignored, ), tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), - concatMap(async ({ tabId, url, ignored }) => { - if (ignored) { - // The next time this host is visited, block again - this._ignoredHostnames.delete(url.hostname); - return; - } - const isPhishing = await phishingDataService.isPhishingWebAddress(url); - if (!isPhishing) { - return; - } - - const phishingWarningPage = new URL( - BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") + - `?phishingUrl=${url.toString()}`, + // Use mergeMap for parallel processing - each tab check runs independently + // Concurrency limit of 5 prevents overwhelming IndexedDB + mergeMap(async ({ tabId, url, ignored }) => { + this._activeSearchCount++; + const searchId = `${tabId}-${Date.now()}`; + logService.debug( + `[PhishingDetectionService] Search STARTED [${searchId}] for ${url.href} (active: ${this._activeSearchCount}/5)`, ); - await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage); - }), + const startTime = performance.now(); + + try { + if (ignored) { + // The next time this host is visited, block again + this._ignoredHostnames.delete(url.hostname); + return; + } + const isPhishing = await phishingDataService.isPhishingWebAddress(url); + if (!isPhishing) { + return; + } + + const phishingWarningPage = new URL( + BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") + + `?phishingUrl=${url.toString()}`, + ); + await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage); + } finally { + this._activeSearchCount--; + const duration = (performance.now() - startTime).toFixed(2); + logService.debug( + `[PhishingDetectionService] Search FINISHED [${searchId}] for ${url.href} in ${duration}ms (active: ${this._activeSearchCount}/5)`, + ); + } + }, 5), ); const onCancelCommand$ = messageListener diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts index 99e101cc199..98835a5b366 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.spec.ts @@ -435,6 +435,89 @@ describe("PhishingIndexedDbService", () => { }); }); + describe("findMatchingUrl", () => { + it("returns true when matcher finds a match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://phishing.net", { url: "https://phishing.net" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("phishing"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledWith("phishing-urls", "readonly"); + expect(mockObjectStore.openCursor).toHaveBeenCalled(); + }); + + it("returns false when no URLs match", async () => { + mockStore.set("https://example.com", { url: "https://example.com" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + + const matcher = (url: string) => url.includes("notfound"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("returns false when store is empty", async () => { + const matcher = (url: string) => url.includes("anything"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + }); + + it("exits early on first match without iterating all records", async () => { + mockStore.set("https://match1.com", { url: "https://match1.com" }); + mockStore.set("https://match2.com", { url: "https://match2.com" }); + mockStore.set("https://match3.com", { url: "https://match3.com" }); + + const matcherCallCount = jest + .fn() + .mockImplementation((url: string) => url.includes("match2")); + await service.findMatchingUrl(matcherCallCount); + + // Matcher should be called for match1.com and match2.com, but NOT match3.com + // because it exits early on first match + expect(matcherCallCount).toHaveBeenCalledWith("https://match1.com"); + expect(matcherCallCount).toHaveBeenCalledWith("https://match2.com"); + expect(matcherCallCount).not.toHaveBeenCalledWith("https://match3.com"); + expect(matcherCallCount).toHaveBeenCalledTimes(2); + }); + + it("supports complex matcher logic", async () => { + mockStore.set("https://example.com/path", { url: "https://example.com/path" }); + mockStore.set("https://test.org", { url: "https://test.org" }); + mockStore.set("https://phishing.net/login", { url: "https://phishing.net/login" }); + + const matcher = (url: string) => { + return url.includes("phishing") && url.includes("login"); + }; + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(true); + }); + + it("returns false on error", async () => { + const error = new Error("IndexedDB error"); + mockOpenRequest.error = error; + (global.indexedDB.open as jest.Mock).mockImplementation(() => { + setTimeout(() => { + mockOpenRequest.onerror?.(); + }, 0); + return mockOpenRequest; + }); + + const matcher = (url: string) => url.includes("test"); + const result = await service.findMatchingUrl(matcher); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingIndexedDbService] Cursor search failed", + expect.any(Error), + ); + }); + }); + describe("database initialization", () => { it("creates object store with keyPath on upgrade", async () => { mockDb.objectStoreNames.contains.mockReturnValue(false); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts index fe0f10da221..ea4b7987607 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-indexeddb.service.ts @@ -195,6 +195,60 @@ export class PhishingIndexedDbService { }); } + /** + * Checks if any URL in the database matches the given matcher function. + * Uses a cursor to iterate through records without loading all into memory. + * Returns immediately on first match for optimal performance. + * + * @param matcher - Function that tests each URL and returns true if it matches + * @returns `true` if any URL matches, `false` if none match or on error + */ + async findMatchingUrl(matcher: (url: string) => boolean): Promise { + this.logService.debug("[PhishingIndexedDbService] Searching for matching URL with cursor..."); + + let db: IDBDatabase | null = null; + try { + db = await this.openDatabase(); + return await this.cursorSearch(db, matcher); + } catch (error) { + this.logService.error("[PhishingIndexedDbService] Cursor search failed", error); + return false; + } finally { + db?.close(); + } + } + + /** + * Performs cursor-based search through all URLs. + * Tests each URL with the matcher without accumulating records in memory. + */ + private cursorSearch(db: IDBDatabase, matcher: (url: string) => boolean): Promise { + return new Promise((resolve, reject) => { + const req = db + .transaction(this.STORE_NAME, "readonly") + .objectStore(this.STORE_NAME) + .openCursor(); + req.onerror = () => reject(req.error); + req.onsuccess = (e) => { + const cursor = (e.target as IDBRequest).result; + if (cursor) { + const url = (cursor.value as PhishingUrlRecord).url; + // Test the URL immediately without accumulating in memory + if (matcher(url)) { + // Found a match + resolve(true); + return; + } + // No match, continue to next record + cursor.continue(); + } else { + // Reached end of records without finding a match + resolve(false); + } + }; + }); + } + /** * Saves phishing URLs directly from a stream. * Processes data incrementally to minimize memory usage. From 748c7c544624eb6154c5318c048c1e196b397dc1 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Mon, 26 Jan 2026 15:55:49 -0800 Subject: [PATCH 52/52] [PM-30303] Migrate Cipher Delete Operations to use SDK (#18275) --- .../bulk-delete-dialog.component.ts | 12 +- .../vault/abstractions/cipher-sdk.service.ts | 74 ++++- .../src/vault/abstractions/cipher.service.ts | 26 +- .../vault/services/cipher-sdk.service.spec.ts | 288 ++++++++++++++++++ .../src/vault/services/cipher-sdk.service.ts | 185 ++++++++++- .../src/vault/services/cipher.service.spec.ts | 254 ++++++++++++++- .../src/vault/services/cipher.service.ts | 79 ++++- 7 files changed, 880 insertions(+), 38 deletions(-) diff --git a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts index 46f2b5da735..9fcb6f0cec1 100644 --- a/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts +++ b/apps/web/src/app/vault/individual-vault/bulk-action-dialogs/bulk-delete-dialog/bulk-delete-dialog.component.ts @@ -12,7 +12,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CollectionId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherBulkDeleteRequest } from "@bitwarden/common/vault/models/request/cipher-bulk-delete.request"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { CenterPositionStrategy, @@ -148,11 +147,16 @@ export class BulkDeleteDialogComponent { } private async deleteCiphersAdmin(ciphers: string[]): Promise { - const deleteRequest = new CipherBulkDeleteRequest(ciphers, this.organization.id); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (this.permanent) { - return await this.apiService.deleteManyCiphersAdmin(deleteRequest); + await this.cipherService.deleteManyWithServer(ciphers, userId, true, this.organization.id); } else { - return await this.apiService.putDeleteManyCiphersAdmin(deleteRequest); + await this.cipherService.softDeleteManyWithServer( + ciphers, + userId, + true, + this.organization.id, + ); } } diff --git a/libs/common/src/vault/abstractions/cipher-sdk.service.ts b/libs/common/src/vault/abstractions/cipher-sdk.service.ts index 1037bfc2b92..3101531eda6 100644 --- a/libs/common/src/vault/abstractions/cipher-sdk.service.ts +++ b/libs/common/src/vault/abstractions/cipher-sdk.service.ts @@ -1,4 +1,4 @@ -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; /** @@ -34,4 +34,76 @@ export abstract class CipherSdkService { originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise; + + /** + * Deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is deleted + */ + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are deleted + */ + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Soft deletes a cipher on the server using the SDK. + * + * @param id The cipher ID to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is soft deleted + */ + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Soft deletes multiple ciphers on the server using the SDK. + * + * @param ids The cipher IDs to soft delete + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @param orgId The organization ID (required when asAdmin is true) + * @returns A promise that resolves when the ciphers are soft deleted + */ + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; + + /** + * Restores a soft-deleted cipher on the server using the SDK. + * + * @param id The cipher ID to restore + * @param userId The user ID to use for SDK client + * @param asAdmin Whether this is an organization admin operation + * @returns A promise that resolves when the cipher is restored + */ + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + + /** + * Restores multiple soft-deleted ciphers on the server using the SDK. + * + * @param ids The cipher IDs to restore + * @param userId The user ID to use for SDK client + * @param orgId The organization ID (determines whether to use admin API) + * @returns A promise that resolves when the ciphers are restored + */ + abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; } diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 1db5f8d38a7..4b544b2a34e 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -230,8 +230,13 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract moveManyWithServer(ids: string[], folderId: string, userId: UserId): Promise; abstract delete(id: string | string[], userId: UserId): Promise; - abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; - abstract deleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; + abstract deleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; abstract deleteAttachment( id: string, revisionDate: string, @@ -247,14 +252,19 @@ export abstract class CipherService implements UserKeyRotationDataProvider number; - abstract softDelete(id: string | string[], userId: UserId): Promise; - abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; - abstract softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin?: boolean): Promise; + abstract softDelete(id: string | string[], userId: UserId): Promise; + abstract softDeleteWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + abstract softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin?: boolean, + orgId?: OrganizationId, + ): Promise; abstract restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], userId: UserId, - ): Promise; - abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; + ): Promise; + abstract restoreWithServer(id: string, userId: UserId, asAdmin?: boolean): Promise; abstract restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise; abstract getKeyForCipherKeyDecryption(cipher: Cipher, userId: UserId): Promise; abstract setAddEditCipherInfo(value: AddEditCipherInfo, userId: UserId): Promise; @@ -275,7 +285,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider; /** - * Decrypts a cipher using either the SDK or the legacy method based on the feature flag. + * Decrypts a cipher using either the use-sdk-cipheroperationsSDK or the legacy method based on the feature flag. * @param cipher The cipher to decrypt. * @param userId The user ID to use for decryption. * @returns A promise that resolves to the decrypted cipher view. diff --git a/libs/common/src/vault/services/cipher-sdk.service.spec.ts b/libs/common/src/vault/services/cipher-sdk.service.spec.ts index bd3feb4619e..cb21ff28133 100644 --- a/libs/common/src/vault/services/cipher-sdk.service.spec.ts +++ b/libs/common/src/vault/services/cipher-sdk.service.spec.ts @@ -28,10 +28,22 @@ describe("DefaultCipherSdkService", () => { mockAdminSdk = { create: jest.fn(), edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), }; mockCiphersSdk = { create: jest.fn(), edit: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + delete_many: jest.fn().mockResolvedValue(undefined), + soft_delete: jest.fn().mockResolvedValue(undefined), + soft_delete_many: jest.fn().mockResolvedValue(undefined), + restore: jest.fn().mockResolvedValue(undefined), + restore_many: jest.fn().mockResolvedValue(undefined), admin: jest.fn().mockReturnValue(mockAdminSdk), }; mockVaultSdk = { @@ -243,4 +255,280 @@ describe("DefaultCipherSdkService", () => { ); }); }); + + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete cipher"), + ); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.deleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.delete_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.deleteManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to delete multiple ciphers"), + ); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should soft delete cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.softDeleteWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete cipher"), + ); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should soft delete multiple ciphers using SDK when asAdmin is false", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should soft delete multiple ciphers using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.soft_delete_many).toHaveBeenCalledWith(testCipherIds, orgId); + }); + + it("should throw error when asAdmin is true but orgId is missing", async () => { + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId, true, undefined), + ).rejects.toThrow("Organization ID is required for admin soft delete."); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow("SDK not available"); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.soft_delete_many.mockRejectedValue(new Error("SDK error")); + + await expect( + cipherSdkService.softDeleteManyWithServer(testCipherIds, userId), + ).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to soft delete multiple ciphers"), + ); + }); + }); + + describe("restoreWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should restore cipher using SDK when asAdmin is false", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, false); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore).toHaveBeenCalledWith(testCipherId); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore cipher using SDK admin API when asAdmin is true", async () => { + await cipherSdkService.restoreWithServer(testCipherId, userId, true); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore).toHaveBeenCalledWith(testCipherId); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreWithServer(testCipherId, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore cipher"), + ); + }); + }); + + describe("restoreManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should restore multiple ciphers using SDK when orgId is not provided", async () => { + await cipherSdkService.restoreManyWithServer(testCipherIds, userId); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.restore_many).toHaveBeenCalledWith(testCipherIds); + expect(mockCiphersSdk.admin).not.toHaveBeenCalled(); + }); + + it("should restore multiple ciphers using SDK admin API when orgId is provided", async () => { + const orgIdString = orgId as string; + await cipherSdkService.restoreManyWithServer(testCipherIds, userId, orgIdString); + + expect(sdkService.userClient$).toHaveBeenCalledWith(userId); + expect(mockVaultSdk.ciphers).toHaveBeenCalled(); + expect(mockCiphersSdk.admin).toHaveBeenCalled(); + expect(mockAdminSdk.restore_many).toHaveBeenCalledWith(testCipherIds, orgIdString); + }); + + it("should throw error and log when SDK client is not available", async () => { + sdkService.userClient$.mockReturnValue(of(null)); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow( + "SDK not available", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + + it("should throw error and log when SDK throws an error", async () => { + mockCiphersSdk.restore_many.mockRejectedValue(new Error("SDK error")); + + await expect(cipherSdkService.restoreManyWithServer(testCipherIds, userId)).rejects.toThrow(); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Failed to restore multiple ciphers"), + ); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher-sdk.service.ts b/libs/common/src/vault/services/cipher-sdk.service.ts index 06f5d3eb961..9757b3d2cc7 100644 --- a/libs/common/src/vault/services/cipher-sdk.service.ts +++ b/libs/common/src/vault/services/cipher-sdk.service.ts @@ -1,8 +1,8 @@ import { firstValueFrom, switchMap, catchError } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; -import { UserId } from "@bitwarden/common/types/guid"; +import { SdkService, asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView as SdkCipherView } from "@bitwarden/sdk-internal"; @@ -79,4 +79,185 @@ export class DefaultCipherSdkService implements CipherSdkService { ), ); } + + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().soft_delete(asUuid(id)); + } else { + await ref.value.vault().ciphers().soft_delete(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete cipher: ${error}`); + throw error; + }), + ), + ); + } + + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + if (orgId == null) { + throw new Error("Organization ID is required for admin soft delete."); + } + await ref.value + .vault() + .ciphers() + .admin() + .soft_delete_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .soft_delete_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to soft delete multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } + + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + if (asAdmin) { + await ref.value.vault().ciphers().admin().restore(asUuid(id)); + } else { + await ref.value.vault().ciphers().restore(asUuid(id)); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore cipher: ${error}`); + throw error; + }), + ), + ); + } + + async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + using ref = sdk.take(); + + // No longer using an asAdmin Param. Org Vault bulkRestore will assess if an item is unassigned or editable + // The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore + if (orgId) { + await ref.value + .vault() + .ciphers() + .admin() + .restore_many( + ids.map((id) => asUuid(id)), + asUuid(orgId), + ); + } else { + await ref.value + .vault() + .ciphers() + .restore_many(ids.map((id) => asUuid(id))); + } + }), + catchError((error: unknown) => { + this.logService.error(`Failed to restore multiple ciphers: ${error}`); + throw error; + }), + ), + ); + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 4f98ba62a1c..07444d5d1c6 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -117,6 +117,8 @@ describe("Cipher Service", () => { let cipherService: CipherService; let encryptionContext: EncryptionContext; + // BehaviorSubject for SDK feature flag - allows tests to change the value after service instantiation + let sdkCrudFeatureFlag$: BehaviorSubject; beforeEach(() => { encryptService.encryptFileData.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES)); @@ -132,6 +134,10 @@ describe("Cipher Service", () => { (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + // Create BehaviorSubject for SDK feature flag - tests can update this to change behavior + sdkCrudFeatureFlag$ = new BehaviorSubject(false); + configService.getFeatureFlag$.mockReturnValue(sdkCrudFeatureFlag$.asObservable()); + cipherService = new CipherService( keyService, domainSettingsService, @@ -280,9 +286,7 @@ describe("Cipher Service", () => { }); it("should delegate to cipherSdkService when feature flag is enabled", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); + sdkCrudFeatureFlag$.next(true); const cipherView = new CipherView(encryptionContext.cipher); const expectedResult = new CipherView(encryptionContext.cipher); @@ -315,9 +319,9 @@ describe("Cipher Service", () => { }); it("should call apiService.putCipherAdmin when orgAdmin param is true", async () => { - configService.getFeatureFlag + configService.getFeatureFlag$ .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(false); + .mockReturnValue(of(false)); const testCipher = new Cipher(cipherData); testCipher.organizationId = orgId; @@ -368,9 +372,7 @@ describe("Cipher Service", () => { }); it("should delegate to cipherSdkService when feature flag is enabled", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); + sdkCrudFeatureFlag$.next(true); const testCipher = new Cipher(cipherData); const cipherView = new CipherView(testCipher); @@ -392,9 +394,7 @@ describe("Cipher Service", () => { }); it("should delegate to cipherSdkService with orgAdmin when feature flag is enabled", async () => { - configService.getFeatureFlag - .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) - .mockResolvedValue(true); + sdkCrudFeatureFlag$.next(true); const testCipher = new Cipher(cipherData); const cipherView = new CipherView(testCipher); @@ -1009,6 +1009,238 @@ describe("Cipher Service", () => { }); }); + describe("deleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should call apiService.deleteCipher when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteCipher").mockResolvedValue(undefined); + + await cipherService.deleteWithServer(testCipherId, userId); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should call apiService.deleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteCipherAdmin").mockResolvedValue(undefined); + + await cipherService.deleteWithServer(testCipherId, userId, true); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should use SDK to delete cipher when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteWithServer(testCipherId, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin delete when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteWithServer(testCipherId, userId, true); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("deleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should call apiService.deleteManyCiphers when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteManyCiphers").mockResolvedValue(undefined); + + await cipherService.deleteManyWithServer(testCipherIds, userId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should call apiService.deleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "deleteManyCiphersAdmin").mockResolvedValue(undefined); + + await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should use SDK to delete multiple ciphers when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteManyWithServer(testCipherIds, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin delete many when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "deleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.deleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("softDeleteWithServer()", () => { + const testCipherId = "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId; + + it("should call apiService.putDeleteCipher when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteCipher").mockResolvedValue(undefined); + + await cipherService.softDeleteWithServer(testCipherId, userId); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should call apiService.putDeleteCipherAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteCipherAdmin").mockResolvedValue(undefined); + + await cipherService.softDeleteWithServer(testCipherId, userId, true); + + expect(apiSpy).toHaveBeenCalledWith(testCipherId); + }); + + it("should use SDK to soft delete cipher when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteWithServer(testCipherId, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, false); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin soft delete when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteWithServer(testCipherId, userId, true); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherId, userId, true); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + + describe("softDeleteManyWithServer()", () => { + const testCipherIds = [ + "5ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b22" as CipherId, + "6ff8c0b2-1d3e-4f8c-9b2d-1d3e4f8c0b23" as CipherId, + ]; + + it("should call apiService.putDeleteManyCiphers when feature flag is disabled", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest.spyOn(apiService, "putDeleteManyCiphers").mockResolvedValue(undefined); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should call apiService.putDeleteManyCiphersAdmin when feature flag is disabled and asAdmin is true", async () => { + configService.getFeatureFlag$ + .calledWith(FeatureFlag.PM27632_SdkCipherCrudOperations) + .mockReturnValue(of(false)); + + const apiSpy = jest + .spyOn(apiService, "putDeleteManyCiphersAdmin") + .mockResolvedValue(undefined); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(apiSpy).toHaveBeenCalled(); + }); + + it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, false); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, false, undefined); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + + it("should use SDK admin soft delete many when feature flag is enabled and asAdmin is true", async () => { + sdkCrudFeatureFlag$.next(true); + + const sdkServiceSpy = jest + .spyOn(cipherSdkService, "softDeleteManyWithServer") + .mockResolvedValue(undefined); + const clearCacheSpy = jest.spyOn(cipherService as any, "clearCache"); + + await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); + + expect(sdkServiceSpy).toHaveBeenCalledWith(testCipherIds, userId, true, orgId); + expect(clearCacheSpy).toHaveBeenCalledWith(userId); + }); + }); + describe("replace (no upsert)", () => { // In order to set up initial state we need to manually update the encrypted state // which will result in an emission. All tests will have this baseline emission. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 53d7666e304..1fc455a1ae9 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -106,6 +106,13 @@ export class CipherService implements CipherServiceAbstraction { */ private clearCipherViewsForUser$: Subject = new Subject(); + /** + * Observable exposing the feature flag status for using the SDK for cipher CRUD operations. + */ + private readonly sdkCipherCrudEnabled$: Observable = this.configService.getFeatureFlag$( + FeatureFlag.PM27632_SdkCipherCrudOperations, + ); + constructor( private keyService: KeyService, private domainSettingsService: DomainSettingsService, @@ -909,9 +916,7 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, orgAdmin?: boolean, ): Promise { - const useSdk = await this.configService.getFeatureFlag( - FeatureFlag.PM27632_SdkCipherCrudOperations, - ); + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { return ( @@ -970,9 +975,7 @@ export class CipherService implements CipherServiceAbstraction { originalCipherView?: CipherView, orgAdmin?: boolean, ): Promise { - const useSdk = await this.configService.getFeatureFlag( - FeatureFlag.PM27632_SdkCipherCrudOperations, - ); + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); if (useSdk) { return await this.updateWithServerUsingSdk(cipherView, userId, originalCipherView, orgAdmin); @@ -1389,7 +1392,14 @@ export class CipherService implements CipherServiceAbstraction { await this.encryptedCiphersState(userId).update(() => ciphers); } - async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async deleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.deleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + if (asAdmin) { await this.apiService.deleteCipherAdmin(id); } else { @@ -1399,7 +1409,19 @@ export class CipherService implements CipherServiceAbstraction { await this.delete(id, userId); } - async deleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { + async deleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.deleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; + } + const request = new CipherBulkDeleteRequest(ids); if (asAdmin) { await this.apiService.deleteManyCiphersAdmin(request); @@ -1539,7 +1561,7 @@ export class CipherService implements CipherServiceAbstraction { }; } - async softDelete(id: string | string[], userId: UserId): Promise { + async softDelete(id: string | string[], userId: UserId): Promise { let ciphers = await firstValueFrom(this.ciphers$(userId)); if (ciphers == null) { return; @@ -1567,7 +1589,14 @@ export class CipherService implements CipherServiceAbstraction { }); } - async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async softDeleteWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.softDeleteWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + if (asAdmin) { await this.apiService.putDeleteCipherAdmin(id); } else { @@ -1577,7 +1606,19 @@ export class CipherService implements CipherServiceAbstraction { await this.softDelete(id, userId); } - async softDeleteManyWithServer(ids: string[], userId: UserId, asAdmin = false): Promise { + async softDeleteManyWithServer( + ids: string[], + userId: UserId, + asAdmin = false, + orgId?: OrganizationId, + ): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.softDeleteManyWithServer(ids, userId, asAdmin, orgId); + await this.clearCache(userId); + return; + } + const request = new CipherBulkDeleteRequest(ids); if (asAdmin) { await this.apiService.putDeleteManyCiphersAdmin(request); @@ -1621,7 +1662,14 @@ export class CipherService implements CipherServiceAbstraction { }); } - async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + async restoreWithServer(id: string, userId: UserId, asAdmin = false): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.restoreWithServer(id, userId, asAdmin); + await this.clearCache(userId); + return; + } + let response; if (asAdmin) { response = await this.apiService.putRestoreCipherAdmin(id); @@ -1637,6 +1685,13 @@ export class CipherService implements CipherServiceAbstraction { * The Org Vault will pass those ids an array as well as the orgId when calling bulkRestore */ async restoreManyWithServer(ids: string[], userId: UserId, orgId?: string): Promise { + const useSdk = await firstValueFrom(this.sdkCipherCrudEnabled$); + if (useSdk) { + await this.cipherSdkService.restoreManyWithServer(ids, userId, orgId); + await this.clearCache(userId); + return; + } + let response; if (orgId) {