From 3228e986af79c84ba793d162d7df3c66443f6e3e Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Fri, 23 Jan 2026 15:22:32 -0500 Subject: [PATCH 001/130] [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 002/130] [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 003/130] 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 004/130] 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 005/130] 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 006/130] [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 007/130] 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 008/130] 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 009/130] 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 010/130] 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 011/130] [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 012/130] [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 013/130] 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 014/130] [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 015/130] 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 016/130] [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 017/130] [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 018/130] 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 019/130] 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 020/130] [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 021/130] 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 022/130] [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 023/130] [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 024/130] [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 025/130] 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 026/130] [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 027/130] [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) { From ec812a7d77b3650d5ddf23b3ceaf45a08cc5b30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 27 Jan 2026 10:46:35 +0100 Subject: [PATCH 028/130] Wire up DI for PRFUnlockService in desktop (#18587) --- .../src/app/services/services.module.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 66613efd115..4fac2555b85 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -33,6 +33,7 @@ import { InternalUserDecryptionOptionsServiceAbstraction, LoginEmailService, SsoUrlService, + UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; @@ -53,6 +54,7 @@ import { import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +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 { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; @@ -123,6 +125,8 @@ import { import { LockComponentService, SessionTimeoutSettingsComponentService, + WebAuthnPrfUnlockService, + DefaultWebAuthnPrfUnlockService, } from "@bitwarden/key-management-ui"; import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { @@ -413,6 +417,21 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopLockComponentService, deps: [], }), + safeProvider({ + provide: WebAuthnPrfUnlockService, + useClass: DefaultWebAuthnPrfUnlockService, + deps: [ + WebAuthnLoginPrfKeyServiceAbstraction, + KeyServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, + EncryptService, + EnvironmentService, + PlatformUtilsServiceAbstraction, + WINDOW, + LogServiceAbstraction, + ConfigService, + ], + }), safeProvider({ provide: CLIENT_TYPE, useValue: ClientType.Desktop, From 9454189df59ed39e3d2e9c321cae2684a7b4c066 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:28:13 +0100 Subject: [PATCH 029/130] [PM-27283] [BEEEP] Reactive `availableVaultTimeoutActions$` in vault timeout settings (#17731) * reactive `availableVaultTimeoutActions$` in vault timeout settings * cleanup * deprecation docs * explicitly provided user id * clearer mocking * better docs --- .../settings/account-security.component.ts | 2 +- .../background-browser-biometrics.service.ts | 2 +- .../extension-lock-component.service.spec.ts | 3 +- .../extension-lock-component.service.ts | 2 +- .../src/app/accounts/settings.component.ts | 2 +- .../account-security-nudge.service.ts | 2 +- .../pin/pin-state.service.abstraction.ts | 21 ++- .../pin/pin-state.service.implementation.ts | 68 +++++--- .../pin/pin-state.service.spec.ts | 50 +++++- .../vault-timeout-settings.service.ts | 5 +- .../vault-timeout-settings.service.spec.ts | 160 ++++++++++++------ .../vault-timeout-settings.service.ts | 127 ++++++-------- .../biometric-state.service.spec.ts | 52 +++--- .../src/biometrics/biometric-state.service.ts | 18 +- 14 files changed, 309 insertions(+), 205 deletions(-) diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 6a3378670bf..1789feebe4e 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -257,7 +257,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { pin: await this.pinService.isPinSet(activeAccount.id), pinLockWithMasterPassword: (await this.pinService.getPinLockType(activeAccount.id)) == "EPHEMERAL", - biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id), enableAutoBiometricsPrompt: await firstValueFrom( this.biometricStateService.promptAutomatically$, ), diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts index c8be58b0bde..d7e755b34ea 100644 --- a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.ts @@ -35,7 +35,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { super(); // Always connect to the native messaging background if biometrics are enabled, not just when it is used // so that there is no wait when used. - const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$; + const biometricsEnabled = this.biometricStateService.biometricUnlockEnabled$(); combineLatest([timer(0, this.BACKGROUND_POLLING_INTERVAL), biometricsEnabled]) .pipe( 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 ecdb899b9a7..934fb9307ee 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 @@ -375,7 +375,7 @@ describe("ExtensionLockComponentService", () => { platformUtilsService.supportsSecureStorage.mockReturnValue( mockInputs.platformSupportsSecureStorage, ); - biometricStateService.biometricUnlockEnabled$ = of(true); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); // PIN pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); @@ -386,6 +386,7 @@ describe("ExtensionLockComponentService", () => { const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); expect(unlockOptions).toEqual(expectedOutput); + expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(userId); }); }); }); 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 5e6e564bbc2..1ed9d1ea967 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 @@ -69,7 +69,7 @@ export class ExtensionLockComponentService implements LockComponentService { return combineLatest([ // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to defer(async () => { - if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$))) { + if (!(await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)))) { return BiometricsStatus.NotEnabledLocally; } else { // TODO remove after 2025.3 diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 3952335af48..f2e828b95ce 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -385,7 +385,7 @@ export class SettingsComponent implements OnInit, OnDestroy { this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(activeAccount.id), ), pin: this.userHasPinSet, - biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(), + biometric: await this.vaultTimeoutSettingsService.isBiometricLockSet(activeAccount.id), requireMasterPasswordOnAppRestart: !(await this.biometricsService.hasPersistentKey( activeAccount.id, )), diff --git a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts index 835c9e35ac7..ab8a1869266 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/account-security-nudge.service.ts @@ -39,7 +39,7 @@ export class AccountSecurityNudgeService extends DefaultSingleNudgeService { this.getNudgeStatus$(nudgeType, userId), of(Date.now() - THIRTY_DAYS_MS), from(this.pinService.isPinSet(userId)), - this.biometricStateService.biometricUnlockEnabled$, + this.biometricStateService.biometricUnlockEnabled$(userId), this.organizationService.organizations$(userId), this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId), ]).pipe( diff --git a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts index 4aef268c1c4..d577d75ef6f 100644 --- a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts +++ b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts @@ -11,6 +11,20 @@ import { PinLockType } from "./pin-lock-type"; * The PinStateService manages the storage and retrieval of PIN-related state for user accounts. */ export abstract class PinStateServiceAbstraction { + /** + * Checks if a user is enrolled into PIN unlock + * @param userId The user's id + * @throws If the user id is not provided + */ + abstract pinSet$(userId: UserId): Observable; + + /** + * Gets the user's {@link PinLockType} + * @param userId The user's id + * @throws If the user id is not provided + */ + abstract pinLockType$(userId: UserId): Observable; + /** * Gets the user's UserKey encrypted PIN * @deprecated - This is not a public API. DO NOT USE IT @@ -21,17 +35,12 @@ export abstract class PinStateServiceAbstraction { /** * Gets the user's {@link PinLockType} + * @deprecated Use {@link pinLockType$} instead * @param userId The user's id * @throws If the user id is not provided */ abstract getPinLockType(userId: UserId): Promise; - /** - * Checks if a user is enrolled into PIN unlock - * @param userId The user's id - */ - abstract isPinSet(userId: UserId): Promise; - /** * Gets the user's PIN-protected UserKey envelope, either persistent or ephemeral based on the provided PinLockType * @deprecated - This is not a public API. DO NOT USE IT diff --git a/libs/common/src/key-management/pin/pin-state.service.implementation.ts b/libs/common/src/key-management/pin/pin-state.service.implementation.ts index d5b2608f280..10046191c01 100644 --- a/libs/common/src/key-management/pin/pin-state.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin-state.service.implementation.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, map, Observable } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; import { StateProvider } from "@bitwarden/state"; @@ -26,27 +26,36 @@ export class PinStateService implements PinStateServiceAbstraction { .pipe(map((value) => (value ? new EncString(value) : null))); } - async isPinSet(userId: UserId): Promise { + pinSet$(userId: UserId): Observable { assertNonNullish(userId, "userId"); - return (await this.getPinLockType(userId)) !== "DISABLED"; + return this.pinLockType$(userId).pipe(map((pinLockType) => pinLockType !== "DISABLED")); + } + + pinLockType$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return combineLatest([ + this.pinProtectedUserKeyEnvelope$(userId, "PERSISTENT").pipe(map((key) => key != null)), + this.stateProvider + .getUserState$(USER_KEY_ENCRYPTED_PIN, userId) + .pipe(map((key) => key != null)), + ]).pipe( + map(([isPersistentPinSet, isPinSet]) => { + if (isPersistentPinSet) { + return "PERSISTENT"; + } else if (isPinSet) { + return "EPHEMERAL"; + } else { + return "DISABLED"; + } + }), + ); } async getPinLockType(userId: UserId): Promise { assertNonNullish(userId, "userId"); - const isPersistentPinSet = - (await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null; - const isPinSet = - (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) != - null; - - if (isPersistentPinSet) { - return "PERSISTENT"; - } else if (isPinSet) { - return "EPHEMERAL"; - } else { - return "DISABLED"; - } + return await firstValueFrom(this.pinLockType$(userId)); } async getPinProtectedUserKeyEnvelope( @@ -55,17 +64,7 @@ export class PinStateService implements PinStateServiceAbstraction { ): Promise { assertNonNullish(userId, "userId"); - if (pinLockType === "EPHEMERAL") { - return await firstValueFrom( - this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId), - ); - } else if (pinLockType === "PERSISTENT") { - return await firstValueFrom( - this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId), - ); - } else { - throw new Error(`Unsupported PinLockType: ${pinLockType}`); - } + return await firstValueFrom(this.pinProtectedUserKeyEnvelope$(userId, pinLockType)); } async setPinState( @@ -110,4 +109,19 @@ export class PinStateService implements PinStateServiceAbstraction { await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId); } + + private pinProtectedUserKeyEnvelope$( + userId: UserId, + pinLockType: PinLockType, + ): Observable { + assertNonNullish(userId, "userId"); + + if (pinLockType === "EPHEMERAL") { + return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, userId); + } else if (pinLockType === "PERSISTENT") { + return this.stateProvider.getUserState$(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, userId); + } else { + throw new Error(`Unsupported PinLockType: ${pinLockType}`); + } + } } diff --git a/libs/common/src/key-management/pin/pin-state.service.spec.ts b/libs/common/src/key-management/pin/pin-state.service.spec.ts index 7406701c28d..42dcce9fedc 100644 --- a/libs/common/src/key-management/pin/pin-state.service.spec.ts +++ b/libs/common/src/key-management/pin/pin-state.service.spec.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; @@ -94,14 +94,50 @@ describe("PinStateService", () => { }); }); - describe("getPinLockType()", () => { + describe("pinSet$", () => { beforeEach(() => { jest.clearAllMocks(); }); it("should throw an error if userId is null", async () => { // Act & Assert - await expect(sut.getPinLockType(null as any)).rejects.toThrow("userId"); + expect(() => sut.pinSet$(null as any)).toThrow("userId"); + }); + + it("should return false when pin lock type is DISABLED", async () => { + // Arrange + jest.spyOn(sut, "pinLockType$").mockReturnValue(of("DISABLED")); + + // Act + const result = await firstValueFrom(sut.pinSet$(mockUserId)); + + // Assert + expect(result).toBe(false); + }); + + it.each([["PERSISTENT" as PinLockType], ["EPHEMERAL" as PinLockType]])( + "should return true when pin lock type is %s", + async (pinLockType) => { + // Arrange + jest.spyOn(sut, "pinLockType$").mockReturnValue(of(pinLockType)); + + // Act + const result = await firstValueFrom(sut.pinSet$(mockUserId)); + + // Assert + expect(result).toBe(true); + }, + ); + }); + + describe("pinLockType$", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should throw an error if userId is null", async () => { + // Act & Assert + expect(() => sut.pinLockType$(null as any)).toThrow("userId"); }); it("should return 'PERSISTENT' if a pin protected user key (persistent) is found", async () => { @@ -114,7 +150,7 @@ describe("PinStateService", () => { ); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("PERSISTENT"); @@ -125,7 +161,7 @@ describe("PinStateService", () => { await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("EPHEMERAL"); @@ -135,7 +171,7 @@ describe("PinStateService", () => { // Arrange - don't set any PIN-related state // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("DISABLED"); @@ -151,7 +187,7 @@ describe("PinStateService", () => { await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId); // Act - const result = await sut.getPinLockType(mockUserId); + const result = await firstValueFrom(sut.pinLockType$(mockUserId)); // Assert expect(result).toBe("DISABLED"); diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts index 697b8a1875c..44108b69513 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts @@ -20,10 +20,9 @@ export abstract class VaultTimeoutSettingsService { /** * Get the available vault timeout actions for the current user * - * **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes * @param userId The user id to check. If not provided, the current user is used */ - abstract availableVaultTimeoutActions$(userId?: string): Observable; + abstract availableVaultTimeoutActions$(userId?: UserId): Observable; /** * Evaluates the user's available vault timeout actions and returns a boolean representing @@ -55,5 +54,5 @@ export abstract class VaultTimeoutSettingsService { * @param userId The user id to check. If not provided, the current user is used * @returns boolean true if biometric lock is set */ - abstract isBiometricLockSet(userId?: string): Promise; + abstract isBiometricLockSet(userId?: UserId): Promise; } 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 3c391344f04..3fa71598e65 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 @@ -78,7 +78,8 @@ describe("VaultTimeoutSettingsService", () => { vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout); - biometricStateService.biometricUnlockEnabled$ = of(false); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); }); afterEach(() => { @@ -86,72 +87,121 @@ describe("VaultTimeoutSettingsService", () => { }); describe("availableVaultTimeoutActions$", () => { - it("always returns LogOut", async () => { - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + describe("when no userId provided (active user)", () => { + it("always returns LogOut", async () => { + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); - expect(result).toContain(VaultTimeoutAction.LogOut); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); + + it("contains Lock when the user has a master password", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith( + mockUserId, + ); + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => { + pinStateService.pinSet$.mockReturnValue(of(true)); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has biometrics configured", async () => { + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); + biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false })); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(), + ); + + expect(result).not.toContain(VaultTimeoutAction.Lock); + }); + + it("should throw error when activeAccount$ is null", async () => { + accountService.activeAccountSubject.next(null); + + const result$ = vaultTimeoutSettingsService.availableVaultTimeoutActions$(); + + await expect(firstValueFrom(result$)).rejects.toThrow("Null or undefined account"); + }); }); - it("contains Lock when the user has a master password", async () => { - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + describe("with explicit userId parameter", () => { + it("should return Lock and LogOut when provided user has master password", async () => { + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(userDecryptionOptionsService.hasMasterPasswordById$).toHaveBeenCalledWith( + mockUserId, + ); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("contains Lock when the user has either a persistent or ephemeral PIN configured", async () => { - pinStateService.isPinSet.mockResolvedValue(true); + it("should return Lock and LogOut when provided user has PIN configured", async () => { + pinStateService.pinSet$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(pinStateService.pinSet$).toHaveBeenCalledWith(mockUserId); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("contains Lock when the user has biometrics configured", async () => { - biometricStateService.biometricUnlockEnabled$ = of(true); - biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true); + it("should return Lock and LogOut when provided user has biometrics configured", async () => { + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(true)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).toContain(VaultTimeoutAction.Lock); - }); + expect(biometricStateService.biometricUnlockEnabled$).toHaveBeenCalledWith(mockUserId); + expect(result).toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); - it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { - userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: false })); - pinStateService.isPinSet.mockResolvedValue(false); - biometricStateService.biometricUnlockEnabled$ = of(false); + it("should not return Lock when provided user has no unlock methods", async () => { + userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false)); + pinStateService.pinSet$.mockReturnValue(of(false)); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(false)); - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); + const result = await firstValueFrom( + vaultTimeoutSettingsService.availableVaultTimeoutActions$(mockUserId), + ); - expect(result).not.toContain(VaultTimeoutAction.Lock); - }); - - it("should return only LogOut when userId is not provided and there is no active account", async () => { - // Set up accountService to return null for activeAccount - accountService.activeAccount$ = of(null); - pinStateService.isPinSet.mockResolvedValue(false); - biometricStateService.biometricUnlockEnabled$ = of(false); - - // Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId - const result = await firstValueFrom( - vaultTimeoutSettingsService.availableVaultTimeoutActions$(), - ); - - // Since there's no active account, userHasMasterPassword returns false, - // meaning no master password is available, so Lock should not be available - expect(result).toEqual([VaultTimeoutAction.LogOut]); - expect(result).not.toContain(VaultTimeoutAction.Lock); + expect(result).not.toContain(VaultTimeoutAction.Lock); + expect(result).toContain(VaultTimeoutAction.LogOut); + }); }); }); @@ -237,8 +287,8 @@ describe("VaultTimeoutSettingsService", () => { `( "returns $expected when policy is $policy, has PIN unlock method: $hasPinUnlock or Biometric unlock method: $hasBiometricUnlock, and user preference is $userPreference", async ({ hasPinUnlock, hasBiometricUnlock, policy, userPreference, expected }) => { - biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(hasBiometricUnlock); - pinStateService.isPinSet.mockResolvedValue(hasPinUnlock); + biometricStateService.biometricUnlockEnabled$.mockReturnValue(of(hasBiometricUnlock)); + pinStateService.pinSet$.mockReturnValue(of(hasPinUnlock)); userDecryptionOptionsSubject.next( new UserDecryptionOptions({ hasMasterPassword: false }), 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 57e484fd767..5384d6860b7 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 @@ -3,16 +3,15 @@ import { catchError, combineLatest, - defer, distinctUntilChanged, EMPTY, firstValueFrom, from, map, + of, Observable, shareReplay, switchMap, - tap, concatMap, } from "rxjs"; @@ -28,6 +27,7 @@ import { PolicyType } from "../../../admin-console/enums"; import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { TokenService } from "../../../auth/abstractions/token.service"; +import { getUserId } from "../../../auth/services/account.service"; import { LogService } from "../../../platform/abstractions/log.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; @@ -101,8 +101,29 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA await this.keyService.refreshAdditionalKeys(userId); } - availableVaultTimeoutActions$(userId?: string): Observable { - return defer(() => this.getAvailableVaultTimeoutActions(userId)); + availableVaultTimeoutActions$(userId?: UserId): Observable { + const userId$ = + userId != null + ? of(userId) + : // TODO remove with https://bitwarden.atlassian.net/browse/PM-10647 + getUserId(this.accountService.activeAccount$); + + return userId$.pipe( + switchMap((userId) => + combineLatest([ + this.userDecryptionOptionsService.hasMasterPasswordById$(userId), + this.biometricStateService.biometricUnlockEnabled$(userId), + this.pinStateService.pinSet$(userId), + ]), + ), + map(([haveMasterPassword, biometricUnlockEnabled, isPinSet]) => { + const canLock = haveMasterPassword || biometricUnlockEnabled || isPinSet; + if (canLock) { + return [VaultTimeoutAction.LogOut, VaultTimeoutAction.Lock]; + } + return [VaultTimeoutAction.LogOut]; + }), + ); } async canLock(userId: UserId): Promise { @@ -112,12 +133,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return availableVaultTimeoutActions?.includes(VaultTimeoutAction.Lock) || false; } - async isBiometricLockSet(userId?: string): Promise { - const biometricUnlockPromise = - userId == null - ? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$) - : this.biometricStateService.getBiometricUnlockEnabled(userId as UserId); - return await biometricUnlockPromise; + async isBiometricLockSet(userId?: UserId): Promise { + return await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$(userId)); } private async setVaultTimeout(userId: UserId, timeout: VaultTimeout): Promise { @@ -262,45 +279,45 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId), this.getMaxSessionTimeoutPolicyDataByUserId$(userId), + this.availableVaultTimeoutActions$(userId), ]).pipe( - switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => { - return from( - this.determineVaultTimeoutAction( - userId, + concatMap( + async ([ + currentVaultTimeoutAction, + maxSessionTimeoutPolicyData, + availableVaultTimeoutActions, + ]) => { + const vaultTimeoutAction = this.determineVaultTimeoutAction( + availableVaultTimeoutActions, currentVaultTimeoutAction, maxSessionTimeoutPolicyData, - ), - ).pipe( - tap((vaultTimeoutAction: VaultTimeoutAction) => { - // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current - // We want to avoid having a null timeout action always so we set it to the default if it is null - // and if the user becomes subject to a policy that requires a specific action, we set it to that - if (vaultTimeoutAction !== currentVaultTimeoutAction) { - return this.stateProvider.setUserState( - VAULT_TIMEOUT_ACTION, - vaultTimeoutAction, - userId, - ); - } - }), - catchError((error: unknown) => { - // Protect outer observable from canceling on error by catching and returning EMPTY - this.logService.error(`Error getting vault timeout: ${error}`); - return EMPTY; - }), - ); + ); + + // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current + // We want to avoid having a null timeout action always so we set it to the default if it is null + // and if the user becomes subject to a policy that requires a specific action, we set it to that + if (vaultTimeoutAction !== currentVaultTimeoutAction) { + await this.stateProvider.setUserState(VAULT_TIMEOUT_ACTION, vaultTimeoutAction, userId); + } + + return vaultTimeoutAction; + }, + ), + catchError((error: unknown) => { + // Protect outer observable from canceling on error by catching and returning EMPTY + this.logService.error(`Error getting vault timeout: ${error}`); + return EMPTY; }), distinctUntilChanged(), // Avoid having the set side effect trigger a new emission of the same action shareReplay({ refCount: true, bufferSize: 1 }), ); } - private async determineVaultTimeoutAction( - userId: string, + private determineVaultTimeoutAction( + availableVaultTimeoutActions: VaultTimeoutAction[], currentVaultTimeoutAction: VaultTimeoutAction | null, maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, - ): Promise { - const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId); + ): VaultTimeoutAction { if (availableVaultTimeoutActions.length === 1) { return availableVaultTimeoutActions[0]; } @@ -339,38 +356,4 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), ); } - - private async getAvailableVaultTimeoutActions(userId?: string): Promise { - userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; - - const availableActions = [VaultTimeoutAction.LogOut]; - - const canLock = - (await this.userHasMasterPassword(userId)) || - (await this.pinStateService.isPinSet(userId as UserId)) || - (await this.isBiometricLockSet(userId)); - - if (canLock) { - availableActions.push(VaultTimeoutAction.Lock); - } - - return availableActions; - } - - private async userHasMasterPassword(userId: string): Promise { - let resolvedUserId: UserId; - if (userId) { - resolvedUserId = userId as UserId; - } else { - const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - if (!activeAccount) { - return false; // No account, can't have master password - } - resolvedUserId = activeAccount.id; - } - - return await firstValueFrom( - this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId), - ); - } } diff --git a/libs/key-management/src/biometrics/biometric-state.service.spec.ts b/libs/key-management/src/biometrics/biometric-state.service.spec.ts index 32043514ff7..2f1f189a897 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.spec.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.spec.ts @@ -179,18 +179,36 @@ describe("BiometricStateService", () => { }); describe("biometricUnlockEnabled$", () => { - it("emits when biometricUnlockEnabled state is updated", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(true); + describe("no user id provided, active user", () => { + it("emits when biometricUnlockEnabled state is updated", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); + }); + + it("emits false when biometricUnlockEnabled state is undefined", async () => { + const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); + state.nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(false); + }); }); - it("emits false when biometricUnlockEnabled state is undefined", async () => { - const state = stateProvider.activeUser.getFake(BIOMETRIC_UNLOCK_ENABLED); - state.nextState(undefined as unknown as boolean); + describe("user id provided", () => { + it("returns biometricUnlockEnabled state for the given user", async () => { + stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(false); + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(true); + }); + + it("returns false when the state is not set", async () => { + stateProvider.singleUser + .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) + .nextState(undefined as unknown as boolean); + + expect(await firstValueFrom(sut.biometricUnlockEnabled$(userId))).toBe(false); + }); }); }); @@ -198,7 +216,7 @@ describe("BiometricStateService", () => { it("updates biometricUnlockEnabled$", async () => { await sut.setBiometricUnlockEnabled(true); - expect(await firstValueFrom(sut.biometricUnlockEnabled$)).toBe(true); + expect(await firstValueFrom(sut.biometricUnlockEnabled$())).toBe(true); }); it("updates state", async () => { @@ -210,22 +228,6 @@ describe("BiometricStateService", () => { }); }); - describe("getBiometricUnlockEnabled", () => { - it("returns biometricUnlockEnabled state for the given user", async () => { - stateProvider.singleUser.getFake(userId, BIOMETRIC_UNLOCK_ENABLED).nextState(true); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(true); - }); - - it("returns false when the state is not set", async () => { - stateProvider.singleUser - .getFake(userId, BIOMETRIC_UNLOCK_ENABLED) - .nextState(undefined as unknown as boolean); - - expect(await sut.getBiometricUnlockEnabled(userId)).toBe(false); - }); - }); - describe("setFingerprintValidated", () => { it("updates fingerprintValidated$", async () => { await sut.setFingerprintValidated(true); diff --git a/libs/key-management/src/biometrics/biometric-state.service.ts b/libs/key-management/src/biometrics/biometric-state.service.ts index 1488f12b50b..ca1cbcfa871 100644 --- a/libs/key-management/src/biometrics/biometric-state.service.ts +++ b/libs/key-management/src/biometrics/biometric-state.service.ts @@ -18,9 +18,11 @@ import { export abstract class BiometricStateService { /** - * `true` if the currently active user has elected to store a biometric key to unlock their vault. + * Returns whether biometric unlock is enabled for a user. + * @param userId The user id to check. If not provided, returns the state for the currently active user. + * @returns An observable that emits `true` if the user has elected to store a biometric key to unlock their vault. */ - abstract biometricUnlockEnabled$: Observable; // used to be biometricUnlock + abstract biometricUnlockEnabled$(userId?: UserId): Observable; /** * If the user has elected to require a password on first unlock of an application instance, this key will store the * encrypted client key half used to unlock the vault. @@ -53,6 +55,7 @@ export abstract class BiometricStateService { /** * Gets the biometric unlock enabled state for the given user. + * @deprecated Use {@link biometricUnlockEnabled$} instead * @param userId user Id to check */ abstract getBiometricUnlockEnabled(userId: UserId): Promise; @@ -103,7 +106,6 @@ export class DefaultBiometricStateService implements BiometricStateService { private promptAutomaticallyState: ActiveUserState; private fingerprintValidatedState: GlobalState; private lastProcessReloadState: GlobalState; - biometricUnlockEnabled$: Observable; encryptedClientKeyHalf$: Observable; promptCancelled$: Observable; promptAutomatically$: Observable; @@ -112,7 +114,6 @@ export class DefaultBiometricStateService implements BiometricStateService { constructor(private stateProvider: StateProvider) { this.biometricUnlockEnabledState = this.stateProvider.getActive(BIOMETRIC_UNLOCK_ENABLED); - this.biometricUnlockEnabled$ = this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); this.encryptedClientKeyHalfState = this.stateProvider.getActive(ENCRYPTED_CLIENT_KEY_HALF); this.encryptedClientKeyHalf$ = this.encryptedClientKeyHalfState.state$.pipe( @@ -142,6 +143,15 @@ export class DefaultBiometricStateService implements BiometricStateService { await this.biometricUnlockEnabledState.update(() => enabled); } + biometricUnlockEnabled$(userId?: UserId): Observable { + if (userId != null) { + return this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)); + } + // Backwards compatibility for active user state + // TODO remove with https://bitwarden.atlassian.net/browse/PM-12043 + return this.biometricUnlockEnabledState.state$.pipe(map(Boolean)); + } + async getBiometricUnlockEnabled(userId: UserId): Promise { return await firstValueFrom( this.stateProvider.getUser(userId, BIOMETRIC_UNLOCK_ENABLED).state$.pipe(map(Boolean)), From 1008bf5cef0127af21485fdaf3a155a7289e4086 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:14:22 +0100 Subject: [PATCH 030/130] [deps] Platform: Update tokio-tracing monorepo (#18238) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 16 ++++++++-------- apps/desktop/desktop_native/Cargo.toml | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 3e5225d4b5a..35228023224 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -3350,9 +3350,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3361,9 +3361,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3372,9 +3372,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3405,9 +3405,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index facd9554af1..da65db59e8c 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -65,8 +65,8 @@ sysinfo = "=0.37.2" thiserror = "=2.0.17" tokio = "=1.48.0" tokio-util = "=0.7.17" -tracing = "=0.1.41" -tracing-subscriber = { version = "=0.3.20", features = [ +tracing = "=0.1.44" +tracing-subscriber = { version = "=0.3.22", features = [ "fmt", "env-filter", "tracing-log", From 144ddee79200b58e511bca181ba05fad928f1679 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Tue, 27 Jan 2026 09:15:51 -0500 Subject: [PATCH 031/130] [PM-30640][PM-30641] update angular core and compiler (#18542) Co-authored-by: Will Martin --- package-lock.json | 118 +++++++++++++++++++++++----------------------- package.json | 18 +++---- 2 files changed, 68 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2cd18e11adc..0605c080574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,15 +14,15 @@ "libs/**/*" ], "dependencies": { - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", "@bitwarden/sdk-internal": "0.2.0-main.470", "@electron/fuses": "1.8.0", @@ -74,7 +74,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -2203,9 +2203,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.15.tgz", - "integrity": "sha512-ikyKfhkxoqQA6JcBN0B9RaN6369sM1XYX81Id0lI58dmWCe7gYfrTp8ejqxxKftl514psQO3pkW8Gn1nJ131Gw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.16.tgz", + "integrity": "sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2214,7 +2214,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15" + "@angular/core": "20.3.16" } }, "node_modules/@angular/build": { @@ -2627,9 +2627,9 @@ } }, "node_modules/@angular/common": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.15.tgz", - "integrity": "sha512-k4mCXWRFiOHK3bUKfWkRQQ8KBPxW8TAJuKLYCsSHPCpMz6u0eA1F0VlrnOkZVKWPI792fOaEAWH2Y4PTaXlUHw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.16.tgz", + "integrity": "sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2638,14 +2638,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.15", + "@angular/core": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.15.tgz", - "integrity": "sha512-lMicIAFAKZXa+BCZWs3soTjNQPZZXrF/WMVDinm8dQcggNarnDj4UmXgKSyXkkyqK5SLfnLsXVzrX6ndVT6z7A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.16.tgz", + "integrity": "sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2655,9 +2655,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.15.tgz", - "integrity": "sha512-8sJoxodxsfyZ8eJ5r6Bx7BCbazXYgsZ1+dE8t5u5rTQ6jNggwNtYEzkyReoD5xvP+MMtRkos3xpwq4rtFnpI6A==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz", + "integrity": "sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw==", "dev": true, "license": "MIT", "dependencies": { @@ -2678,7 +2678,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -2864,9 +2864,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.15.tgz", - "integrity": "sha512-NMbX71SlTZIY9+rh/SPhRYFJU0pMJYW7z/TBD4lqiO+b0DTOIg1k7Pg9ydJGqSjFO1Z4dQaA6TteNuF99TJCNw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.16.tgz", + "integrity": "sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2875,7 +2875,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.15", + "@angular/compiler": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -2889,9 +2889,9 @@ } }, "node_modules/@angular/forms": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.15.tgz", - "integrity": "sha512-gS5hQkinq52pm/7mxz4yHPCzEcmRWjtUkOVddPH0V1BW/HMni/p4Y6k2KqKBeGb9p8S5EAp6PDxDVLOPukp3mg==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.16.tgz", + "integrity": "sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2900,16 +2900,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.15.tgz", - "integrity": "sha512-TxRM/wTW/oGXv/3/Iohn58yWoiYXOaeEnxSasiGNS1qhbkcKtR70xzxW6NjChBUYAixz2ERkLURkpx3pI8Q6Dw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.16.tgz", + "integrity": "sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2918,9 +2918,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.15", - "@angular/common": "20.3.15", - "@angular/core": "20.3.15" + "@angular/animations": "20.3.16", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -2929,9 +2929,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.15.tgz", - "integrity": "sha512-RizuRdBt0d6ongQ2y8cr8YsXFyjF8f91vFfpSNw+cFj+oiEmRC1txcWUlH5bPLD9qSDied8qazUi0Tb8VPQDGw==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz", + "integrity": "sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2940,16 +2940,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15" + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16" } }, "node_modules/@angular/router": { - "version": "20.3.15", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.15.tgz", - "integrity": "sha512-6+qgk8swGSoAu7ISSY//GatAyCP36hEvvUgvjbZgkXLLH9yUQxdo77ij05aJ5s0OyB25q/JkqS8VTY0z1yE9NQ==", + "version": "20.3.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.16.tgz", + "integrity": "sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -2958,9 +2958,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.15", - "@angular/core": "20.3.15", - "@angular/platform-browser": "20.3.15", + "@angular/common": "20.3.16", + "@angular/core": "20.3.16", + "@angular/platform-browser": "20.3.16", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -32414,9 +32414,9 @@ "license": "MIT" }, "node_modules/msgpackr": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", - "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", + "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", "dev": true, "license": "MIT", "optional": true, @@ -34690,9 +34690,9 @@ } }, "node_modules/ordered-binary": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.0.tgz", - "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", "dev": true, "license": "MIT", "optional": true diff --git a/package.json b/package.json index 8455d97c87c..e2b65ccbef9 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@angular-devkit/build-angular": "20.3.12", "@angular-eslint/schematics": "20.7.0", "@angular/cli": "20.3.12", - "@angular/compiler-cli": "20.3.15", + "@angular/compiler-cli": "20.3.16", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "@compodoc/compodoc": "1.1.32", @@ -153,15 +153,15 @@ "webpack-node-externals": "3.0.0" }, "dependencies": { - "@angular/animations": "20.3.15", + "@angular/animations": "20.3.16", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.15", - "@angular/compiler": "20.3.15", - "@angular/core": "20.3.15", - "@angular/forms": "20.3.15", - "@angular/platform-browser": "20.3.15", - "@angular/platform-browser-dynamic": "20.3.15", - "@angular/router": "20.3.15", + "@angular/common": "20.3.16", + "@angular/compiler": "20.3.16", + "@angular/core": "20.3.16", + "@angular/forms": "20.3.16", + "@angular/platform-browser": "20.3.16", + "@angular/platform-browser-dynamic": "20.3.16", + "@angular/router": "20.3.16", "@bitwarden/sdk-internal": "0.2.0-main.470", "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", "@electron/fuses": "1.8.0", From 8b9ee0df0684a5bc8a18b4e727c77dc36a083c73 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Tue, 27 Jan 2026 22:48:20 +0800 Subject: [PATCH 032/130] fix(importer): preserve protected KeePass custom fields as hidden fields (#18136) Protected fields (ProtectInMemory="True") were being appended to notes when they exceeded 200 characters or contained newlines, instead of being imported as hidden custom fields. Now protected fields are always imported as hidden fields regardless of their length or content, preserving their protected status. Fixes #16897 Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com> --- .../importers/keepass2-xml-importer.spec.ts | 71 +++++++++++++++++++ .../src/importers/keepass2-xml-importer.ts | 23 ++++-- .../keepass2-xml-importer-testdata.ts | 51 +++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) diff --git a/libs/importer/src/importers/keepass2-xml-importer.spec.ts b/libs/importer/src/importers/keepass2-xml-importer.spec.ts index 8fbb021883c..c1c0947936b 100644 --- a/libs/importer/src/importers/keepass2-xml-importer.spec.ts +++ b/libs/importer/src/importers/keepass2-xml-importer.spec.ts @@ -1,3 +1,4 @@ +import { FieldType } from "@bitwarden/common/vault/enums"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KeePass2XmlImporter } from "./keepass2-xml-importer"; @@ -5,6 +6,7 @@ import { TestData, TestData1, TestData2, + TestDataWithProtectedFields, } from "./spec-data/keepass2-xml/keepass2-xml-importer-testdata"; describe("KeePass2 Xml Importer", () => { @@ -43,4 +45,73 @@ describe("KeePass2 Xml Importer", () => { const result = await importer.parse(TestData2); expect(result.success).toBe(false); }); + + describe("protected fields handling", () => { + it("should import protected custom fields as hidden fields", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + + const cipher = result.ciphers[0]; + expect(cipher.name).toBe("Test Entry"); + expect(cipher.login.username).toBe("testuser"); + expect(cipher.login.password).toBe("testpass"); + expect(cipher.notes).toContain("Regular notes"); + + // Check that protected custom field is imported as hidden field + const protectedField = cipher.fields.find((f) => f.name === "SAFE UN-LOCKING instructions"); + expect(protectedField).toBeDefined(); + expect(protectedField?.value).toBe("Secret instructions here"); + expect(protectedField?.type).toBe(FieldType.Hidden); + + // Check that regular custom field is imported as text field + const regularField = cipher.fields.find((f) => f.name === "CustomField"); + expect(regularField).toBeDefined(); + expect(regularField?.value).toBe("Custom value"); + expect(regularField?.type).toBe(FieldType.Text); + }); + + it("should import long protected fields as hidden fields (not appended to notes)", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + + // Long protected field should be imported as hidden field + const longField = cipher.fields.find((f) => f.name === "LongProtectedField"); + expect(longField).toBeDefined(); + expect(longField?.type).toBe(FieldType.Hidden); + expect(longField?.value).toContain("This is a very long protected field"); + + // Should not be appended to notes + expect(cipher.notes).not.toContain("LongProtectedField"); + }); + + it("should import multiline protected fields as hidden fields (not appended to notes)", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + + // Multiline protected field should be imported as hidden field + const multilineField = cipher.fields.find((f) => f.name === "MultilineProtectedField"); + expect(multilineField).toBeDefined(); + expect(multilineField?.type).toBe(FieldType.Hidden); + expect(multilineField?.value).toContain("Line 1"); + + // Should not be appended to notes + expect(cipher.notes).not.toContain("MultilineProtectedField"); + }); + + it("should not append protected custom fields to notes", async () => { + const importer = new KeePass2XmlImporter(); + const result = await importer.parse(TestDataWithProtectedFields); + + const cipher = result.ciphers[0]; + expect(cipher.notes).not.toContain("SAFE UN-LOCKING instructions"); + expect(cipher.notes).not.toContain("Secret instructions here"); + }); + }); }); diff --git a/libs/importer/src/importers/keepass2-xml-importer.ts b/libs/importer/src/importers/keepass2-xml-importer.ts index 0af7a6f829c..429ab2aa1b7 100644 --- a/libs/importer/src/importers/keepass2-xml-importer.ts +++ b/libs/importer/src/importers/keepass2-xml-importer.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { FieldType } from "@bitwarden/common/vault/enums"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ImportResult } from "../models/import-result"; @@ -92,16 +93,26 @@ export class KeePass2XmlImporter extends BaseImporter implements Importer { } else if (key === "Notes") { cipher.notes += value + "\n"; } else { - let type = FieldType.Text; const attrs = valueEl.attributes as any; - if ( + const isProtected = attrs.length > 0 && attrs.ProtectInMemory != null && - attrs.ProtectInMemory.value === "True" - ) { - type = FieldType.Hidden; + attrs.ProtectInMemory.value === "True"; + + if (isProtected) { + // Protected fields should always be imported as hidden fields, + // regardless of length or newlines (fixes #16897) + if (cipher.fields == null) { + cipher.fields = []; + } + const field = new FieldView(); + field.type = FieldType.Hidden; + field.name = key; + field.value = value; + cipher.fields.push(field); + } else { + this.processKvp(cipher, key, value, FieldType.Text); } - this.processKvp(cipher, key, value, type); } }); diff --git a/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts b/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts index e06ca2cf655..9e1599b7078 100644 --- a/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts +++ b/libs/importer/src/importers/spec-data/keepass2-xml/keepass2-xml-importer-testdata.ts @@ -354,6 +354,57 @@ line2 `; +export const TestDataWithProtectedFields = ` + + + + KvS57lVwl13AfGFLwkvq4Q== + Root + + fAa543oYlgnJKkhKag5HLw== + + Title + Test Entry + + + UserName + testuser + + + Password + testpass + + + URL + https://example.com + + + Notes + Regular notes + + + SAFE UN-LOCKING instructions + Secret instructions here + + + CustomField + Custom value + + + LongProtectedField + This is a very long protected field value that exceeds 200 characters. It contains sensitive information that should be imported as a hidden field and not appended to the notes section. This text is long enough to trigger the old behavior. + + + MultilineProtectedField + Line 1 +Line 2 +Line 3 + + + + +`; + export const TestData2 = ` KeePass From fe1410bed31a3e90c2bbb13963246f966ffab51a Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Tue, 27 Jan 2026 09:53:03 -0500 Subject: [PATCH 033/130] [PM-30375] Account for differences in RoboForm Windows desktop app CSV export headers (#18403) --- libs/importer/src/importers/roboform-csv-importer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/importer/src/importers/roboform-csv-importer.ts b/libs/importer/src/importers/roboform-csv-importer.ts index eb8a1ceac6a..6f557bb0db5 100644 --- a/libs/importer/src/importers/roboform-csv-importer.ts +++ b/libs/importer/src/importers/roboform-csv-importer.ts @@ -29,8 +29,9 @@ export class RoboFormCsvImporter extends BaseImporter implements Importer { cipher.notes = this.getValueOrDefault(value.Note); cipher.name = this.getValueOrDefault(value.Name, "--"); cipher.login.username = this.getValueOrDefault(value.Login); - cipher.login.password = this.getValueOrDefault(value.Pwd); - cipher.login.uris = this.makeUriArray(value.Url); + cipher.login.password = + this.getValueOrDefault(value.Pwd) ?? this.getValueOrDefault(value.Password); + cipher.login.uris = this.makeUriArray(value.Url) ?? this.makeUriArray(value.URL); if (!this.isNullOrWhitespace(value.Rf_fields)) { this.parseRfFields(cipher, value); From 00cf24972d944638bbd1adc00a0ae3eeabb6eb9a Mon Sep 17 00:00:00 2001 From: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:28:02 +0100 Subject: [PATCH 034/130] [PM-28079] Add attributes to filter for the mutationObserver (#17832) * [PM-28079] Add attributes to filter for the mutationObserver * Update attributes based on Claude suggestions * Updated remaining attributes * Adjust placeholder check in `updateAutofillFieldElementData` * Update ordering of constants and add comment * Remove `tagName` and `value` from mutation logic * Add new autocomplete and aria attributes to `updateActions` * Fix autocomplete handlers * Fix broken test for `updateAttributes` * Order attributes for readability in `updateActions` * Fix tests --------- Co-authored-by: Jonathan Prusik --- .../collect-autofill-content.service.spec.ts | 12 +- .../collect-autofill-content.service.ts | 121 +++++++++++------- libs/common/src/autofill/constants/index.ts | 35 +++++ 3 files changed, 116 insertions(+), 52 deletions(-) diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 66a692dbe20..58f3ad11166 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -158,7 +158,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -346,7 +346,7 @@ describe("CollectAutofillContentService", () => { type: "text", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -379,7 +379,7 @@ describe("CollectAutofillContentService", () => { type: "password", value: "", checked: false, - autoCompleteType: "", + autoCompleteType: null, disabled: false, readonly: false, selectInfo: null, @@ -588,7 +588,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -621,7 +621,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: "", + autoCompleteType: null, checked: false, "data-stripe": null, disabled: false, @@ -2507,9 +2507,7 @@ describe("CollectAutofillContentService", () => { "class", "tabindex", "title", - "value", "rel", - "tagname", "checked", "disabled", "readonly", diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index 117c7c5e2a4..1d464e1313f 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AUTOFILL_ATTRIBUTES } from "@bitwarden/common/autofill/constants"; + import AutofillField from "../models/autofill-field"; import AutofillForm from "../models/autofill-form"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -242,10 +244,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this._autofillFormElements.set(formElement, { opid: formElement.opid, htmlAction: this.getFormActionAttribute(formElement), - htmlName: this.getPropertyOrAttribute(formElement, "name"), - htmlClass: this.getPropertyOrAttribute(formElement, "class"), - htmlID: this.getPropertyOrAttribute(formElement, "id"), - htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + htmlName: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.CLASS), + htmlID: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.ID), + htmlMethod: this.getPropertyOrAttribute(formElement, AUTOFILL_ATTRIBUTES.METHOD), }); } @@ -260,7 +262,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ * @private */ private getFormActionAttribute(element: ElementWithOpId): string { - return new URL(this.getPropertyOrAttribute(element, "action"), globalThis.location.href).href; + return new URL( + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ACTION), + globalThis.location.href, + ).href; } /** @@ -335,7 +340,10 @@ export class CollectAutofillContentService implements CollectAutofillContentServ return priorityFormFields; } - const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + const fieldType = this.getPropertyOrAttribute( + element, + AUTOFILL_ATTRIBUTES.TYPE, + )?.toLowerCase(); if (unimportantFieldTypesSet.has(fieldType)) { unimportantFormFields.push(element); continue; @@ -384,11 +392,11 @@ export class CollectAutofillContentService implements CollectAutofillContentServ elementNumber: index, maxLength: this.getAutofillFieldMaxLength(element), viewable: await this.domElementVisibilityService.isElementViewable(element), - htmlID: this.getPropertyOrAttribute(element, "id"), - htmlName: this.getPropertyOrAttribute(element, "name"), - htmlClass: this.getPropertyOrAttribute(element, "class"), - tabindex: this.getPropertyOrAttribute(element, "tabindex"), - title: this.getPropertyOrAttribute(element, "title"), + htmlID: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ID), + htmlName: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.NAME), + htmlClass: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.CLASS), + tabindex: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TABINDEX), + title: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.TITLE), tagName: this.getAttributeLowerCase(element, "tagName"), dataSetValues: this.getDataSetValues(element), }; @@ -404,16 +412,16 @@ export class CollectAutofillContentService implements CollectAutofillContentServ } let autofillFieldLabels = {}; - const elementType = this.getAttributeLowerCase(element, "type"); + const elementType = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE); if (elementType !== "hidden") { autofillFieldLabels = { "label-tag": this.createAutofillFieldLabelTag(element as FillableFormFieldElement), - "label-data": this.getPropertyOrAttribute(element, "data-label"), - "label-aria": this.getPropertyOrAttribute(element, "aria-label"), + "label-data": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_LABEL), + "label-aria": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.ARIA_LABEL), "label-top": this.createAutofillFieldTopLabel(element), "label-right": this.createAutofillFieldRightLabel(element), "label-left": this.createAutofillFieldLeftLabel(element), - placeholder: this.getPropertyOrAttribute(element, "placeholder"), + placeholder: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.PLACEHOLDER), }; } @@ -421,21 +429,21 @@ export class CollectAutofillContentService implements CollectAutofillContentServ const autofillField = { ...autofillFieldBase, ...autofillFieldLabels, - rel: this.getPropertyOrAttribute(element, "rel"), + rel: this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.REL), type: elementType, value: this.getElementValue(element), - checked: this.getAttributeBoolean(element, "checked"), + checked: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED), autoCompleteType: this.getAutoCompleteAttribute(element), - disabled: this.getAttributeBoolean(element, "disabled"), - readonly: this.getAttributeBoolean(element, "readonly"), + disabled: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED), + readonly: this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY), selectInfo: elementIsSelectElement(element) ? this.getSelectElementOptions(element as HTMLSelectElement) : null, form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null, - "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), - "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), - "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), - "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + "aria-hidden": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, true), + "aria-disabled": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_DISABLED, true), + "aria-haspopup": this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, true), + "data-stripe": this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.DATA_STRIPE), }; this.cacheAutofillFieldElement(index, element, autofillField); @@ -467,9 +475,9 @@ export class CollectAutofillContentService implements CollectAutofillContentServ */ private getAutoCompleteAttribute(element: ElementWithOpId): string { return ( - this.getPropertyOrAttribute(element, "x-autocompletetype") || - this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete") + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.X_AUTOCOMPLETE_TYPE) || + this.getPropertyOrAttribute(element, AUTOFILL_ATTRIBUTES.AUTOCOMPLETE_TYPE) ); } @@ -957,6 +965,8 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); this.mutationObserver.observe(document.documentElement, { attributes: true, + /** Mutations to node attributes NOT on this list will not be observed! */ + attributeFilter: Object.values(AUTOFILL_ATTRIBUTES), childList: true, subtree: true, }); @@ -1321,6 +1331,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), name: () => updateAttribute("htmlName"), id: () => updateAttribute("htmlID"), + class: () => updateAttribute("htmlClass"), method: () => updateAttribute("htmlMethod"), }; @@ -1350,29 +1361,49 @@ export class CollectAutofillContentService implements CollectAutofillContentServ this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); }; const updateActions: Record = { - maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), - id: () => updateAttribute("htmlID"), - name: () => updateAttribute("htmlName"), - class: () => updateAttribute("htmlClass"), - tabindex: () => updateAttribute("tabindex"), - title: () => updateAttribute("tabindex"), - rel: () => updateAttribute("rel"), - tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), - type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), - value: () => (dataTarget.value = this.getElementValue(element)), - checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), - disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), - readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), - autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), - "data-label": () => updateAttribute("label-data"), + "aria-describedby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_DESCRIBEDBY), "aria-label": () => updateAttribute("label-aria"), + "aria-labelledby": () => updateAttribute(AUTOFILL_ATTRIBUTES.ARIA_LABELLEDBY), "aria-hidden": () => - (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + (dataTarget["aria-hidden"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HIDDEN, + true, + )), "aria-disabled": () => - (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + (dataTarget["aria-disabled"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_DISABLED, + true, + )), "aria-haspopup": () => - (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), - "data-stripe": () => updateAttribute("data-stripe"), + (dataTarget["aria-haspopup"] = this.getAttributeBoolean( + element, + AUTOFILL_ATTRIBUTES.ARIA_HASPOPUP, + true, + )), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + autocompletetype: () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "x-autocompletetype": () => + (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + class: () => updateAttribute("htmlClass"), + checked: () => + (dataTarget.checked = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.CHECKED)), + "data-label": () => updateAttribute("label-data"), + "data-stripe": () => updateAttribute(AUTOFILL_ATTRIBUTES.DATA_STRIPE), + disabled: () => + (dataTarget.disabled = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.DISABLED)), + id: () => updateAttribute("htmlID"), + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + name: () => updateAttribute("htmlName"), + placeholder: () => updateAttribute(AUTOFILL_ATTRIBUTES.PLACEHOLDER), + readonly: () => + (dataTarget.readonly = this.getAttributeBoolean(element, AUTOFILL_ATTRIBUTES.READONLY)), + rel: () => updateAttribute(AUTOFILL_ATTRIBUTES.REL), + tabindex: () => updateAttribute(AUTOFILL_ATTRIBUTES.TABINDEX), + title: () => updateAttribute(AUTOFILL_ATTRIBUTES.TITLE), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, AUTOFILL_ATTRIBUTES.TYPE)), }; if (!updateActions[attributeName]) { diff --git a/libs/common/src/autofill/constants/index.ts b/libs/common/src/autofill/constants/index.ts index dc79e27b6aa..f3f0077a37f 100644 --- a/libs/common/src/autofill/constants/index.ts +++ b/libs/common/src/autofill/constants/index.ts @@ -28,6 +28,41 @@ export const EVENTS = { SUBMIT: "submit", } as const; +/** + * HTML attributes observed by the MutationObserver for autofill form/field tracking. + * If you need to observe a new attribute, add it here. + */ +export const AUTOFILL_ATTRIBUTES = { + ACTION: "action", + ARIA_DESCRIBEDBY: "aria-describedby", + ARIA_DISABLED: "aria-disabled", + ARIA_HASPOPUP: "aria-haspopup", + ARIA_HIDDEN: "aria-hidden", + ARIA_LABEL: "aria-label", + ARIA_LABELLEDBY: "aria-labelledby", + AUTOCOMPLETE: "autocomplete", + AUTOCOMPLETE_TYPE: "autocompletetype", + X_AUTOCOMPLETE_TYPE: "x-autocompletetype", + CHECKED: "checked", + CLASS: "class", + DATA_LABEL: "data-label", + DATA_STRIPE: "data-stripe", + DISABLED: "disabled", + ID: "id", + MAXLENGTH: "maxlength", + METHOD: "method", + NAME: "name", + PLACEHOLDER: "placeholder", + POPOVER: "popover", + POPOVERTARGET: "popovertarget", + POPOVERTARGETACTION: "popovertargetaction", + READONLY: "readonly", + REL: "rel", + TABINDEX: "tabindex", + TITLE: "title", + TYPE: "type", +} as const; + export const ClearClipboardDelay = { Never: null as null, TenSeconds: 10, From 1de2e33bbbf10211f1b3b3354c1ef165b7e92a08 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:14:00 -0800 Subject: [PATCH 035/130] [PM-31182] Add HIBP icons URL to dev configuration for allowed Content-Security-Policy domains (#18565) * add url for loading HIBP icons * remove old hibp location --- apps/web/webpack.base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index cc17b3b7cfd..016d2b0fe61 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -319,7 +319,7 @@ module.exports.buildConfig = function buildConfig(params) { https://*.paypal.com https://www.paypalobjects.com https://q.stripe.com - https://haveibeenpwned.com + https://logos.haveibeenpwned.com ;media-src 'self' https://assets.bitwarden.com From cf6d02fafa5d3a71ef1a78b95603b03cde201128 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 27 Jan 2026 19:00:13 +0100 Subject: [PATCH 036/130] [PM-31264] Broken vault filters in milestone-1 (#18589) * Fix vault filters Now uses the same `createFilterFunction` as web rather than the custom proxy like approach. * Remove provide --- .../src/vault/app/vault-v3/vault.component.ts | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) 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 9d5fad2fe4c..efb7e4de70f 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { + combineLatest, firstValueFrom, Subject, takeUntil, @@ -70,6 +71,7 @@ import { CipherFormModule, CipherViewComponent, CollectionAssignmentResult, + createFilterFunction, DecryptionFailureDialogComponent, DefaultChangeLoginPasswordService, DefaultCipherFormConfigService, @@ -79,6 +81,7 @@ import { VaultFilter, VaultFilterServiceAbstraction as VaultFilterService, RoutedVaultFilterBridgeService, + RoutedVaultFilterService, VaultItemsTransferService, DefaultVaultItemsTransferService, } from "@bitwarden/vault"; @@ -216,6 +219,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { private policyService: PolicyService, private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, + private routedVaultFilterService: RoutedVaultFilterService, private vaultFilterService: VaultFilterService, private vaultItemTransferService: VaultItemsTransferService, ) {} @@ -234,9 +238,16 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { }); // Subscribe to filter changes from router params via the bridge service - this.routedVaultFilterBridgeService.activeFilter$ + // Use combineLatest to react to changes in both the filter and archive flag + combineLatest([ + this.routedVaultFilterBridgeService.activeFilter$, + this.routedVaultFilterService.filter$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]) .pipe( - switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))), + switchMap(([vaultFilter, routedFilter, archiveEnabled]) => + from(this.applyVaultFilter(vaultFilter, routedFilter, archiveEnabled)), + ), takeUntil(this.componentIsDestroyed$), ) .subscribe(); @@ -789,48 +800,19 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { await this.go().catch(() => {}); } - /** - * Wraps a filter function to handle CipherListView objects. - * CipherListView has a different type structure where type can be a string or object. - * This wrapper converts it to CipherView-compatible structure before filtering. - */ - private wrapFilterForCipherListView( - filterFn: (cipher: CipherView) => boolean, - ): (cipher: CipherViewLike) => boolean { - return (cipher: CipherViewLike) => { - // For CipherListView, create a proxy object with the correct type property - if (CipherViewLikeUtils.isCipherListView(cipher)) { - const proxyCipher = { - ...cipher, - 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), - }; - return filterFn(proxyCipher as any); - } - return filterFn(cipher); - }; - } - - async applyVaultFilter(vaultFilter: VaultFilter) { + async applyVaultFilter( + vaultFilter: VaultFilter, + routedFilter: Parameters[0], + archiveEnabled: boolean, + ) { this.searchBarService.setPlaceholderText( this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), ); this.activeFilter = vaultFilter; - const originalFilterFn = this.activeFilter.buildFilter(); - const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn); + const filterFn = createFilterFunction(routedFilter, archiveEnabled); - await this.vaultItemsComponent?.reload( - wrappedFilterFn, - vaultFilter.isDeleted, - vaultFilter.isArchived, - ); + await this.vaultItemsComponent?.reload(filterFn, vaultFilter.isDeleted, vaultFilter.isArchived); } private getAvailableCollections(cipher: CipherView): CollectionView[] { From 1b94d16f31347a9c49dc3e459d19a4472c9e6c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 27 Jan 2026 19:08:07 +0100 Subject: [PATCH 037/130] PM-31294: Unlock Passkey using getWebVaultUrl over getHostname (#18597) --- .../src/lock/services/default-webauthn-prf-unlock.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 960a663b589..106037bc5f7 100644 --- 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 @@ -14,6 +14,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co 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 { Utils } from "@bitwarden/common/platform/misc/utils"; 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"; @@ -267,7 +268,7 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService private async getRpIdForUser(userId: UserId): Promise { try { const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId)); - const hostname = environment.getHostname(); + const hostname = Utils.getHost(environment.getWebVaultUrl()); // The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host. if (!hostname) { From 3e344212d6860afc94b24cd05e4057d7e8c97116 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 27 Jan 2026 12:18:37 -0600 Subject: [PATCH 038/130] [PM-29805] - Rollback single org enablement when auto confirm enablement fails. (#18572) --- ...nfirm-edit-policy-dialog.component.spec.ts | 270 ++++++++++++++++++ ...to-confirm-edit-policy-dialog.component.ts | 37 ++- 2 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts new file mode 100644 index 00000000000..09b2f8961f3 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.spec.ts @@ -0,0 +1,270 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { + AutoConfirmPolicyDialogComponent, + AutoConfirmPolicyDialogData, +} from "./auto-confirm-edit-policy-dialog.component"; + +describe("AutoConfirmPolicyDialogComponent", () => { + let component: AutoConfirmPolicyDialogComponent; + let fixture: ComponentFixture; + + let mockPolicyApiService: MockProxy; + let mockAccountService: FakeAccountService; + let mockOrganizationService: MockProxy; + let mockPolicyService: MockProxy; + let mockRouter: MockProxy; + let mockAutoConfirmService: MockProxy; + let mockDialogRef: MockProxy; + let mockToastService: MockProxy; + let mockI18nService: MockProxy; + let mockKeyService: MockProxy; + + const mockUserId = newGuid() as UserId; + const mockOrgId = newGuid() as OrganizationId; + + const mockDialogData: AutoConfirmPolicyDialogData = { + organizationId: mockOrgId, + policy: { + name: "autoConfirm", + description: "Auto Confirm Policy", + type: PolicyType.AutoConfirm, + component: {} as any, + showDescription: true, + display$: () => of(true), + }, + firstTimeDialog: false, + }; + + const mockOrg = { + id: mockOrgId, + name: "Test Organization", + enabled: true, + isAdmin: true, + canManagePolicies: true, + } as Organization; + + beforeEach(async () => { + mockPolicyApiService = mock(); + mockAccountService = mockAccountServiceWith(mockUserId); + mockOrganizationService = mock(); + mockPolicyService = mock(); + mockRouter = mock(); + mockAutoConfirmService = mock(); + mockDialogRef = mock(); + mockToastService = mock(); + mockI18nService = mock(); + mockKeyService = mock(); + + mockPolicyService.policies$.mockReturnValue(of([])); + mockOrganizationService.organizations$.mockReturnValue(of([mockOrg])); + + await TestBed.configureTestingModule({ + imports: [AutoConfirmPolicyDialogComponent], + providers: [ + FormBuilder, + { provide: DIALOG_DATA, useValue: mockDialogData }, + { provide: AccountService, useValue: mockAccountService }, + { provide: PolicyApiServiceAbstraction, useValue: mockPolicyApiService }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DialogRef, useValue: mockDialogRef }, + { provide: ToastService, useValue: mockToastService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: PolicyService, useValue: mockPolicyService }, + { provide: Router, useValue: mockRouter }, + { provide: AutomaticUserConfirmationService, useValue: mockAutoConfirmService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AutoConfirmPolicyDialogComponent, { + set: { template: "
" }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(AutoConfirmPolicyDialogComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("handleSubmit", () => { + beforeEach(() => { + // Mock the policyComponent + component.policyComponent = { + buildRequest: jest.fn().mockResolvedValue({ enabled: true, data: null }), + enabled: { value: true }, + setSingleOrgEnabled: jest.fn(), + } as any; + + mockAutoConfirmService.configuration$.mockReturnValue( + of({ enabled: false, showSetupDialog: true, showBrowserNotification: undefined }), + ); + mockAutoConfirmService.upsert.mockResolvedValue(undefined); + mockI18nService.t.mockReturnValue("Policy updated"); + }); + + it("should enable SingleOrg policy when it was not already enabled", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + // Call handleSubmit with singleOrgEnabled = false (meaning it needs to be enabled) + await component["handleSubmit"](false); + + // First call should be SingleOrg enable + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should not enable SingleOrg policy when it was already enabled", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + // Call handleSubmit with singleOrgEnabled = true (meaning it's already enabled) + await component["handleSubmit"](true); + + // Should only call putPolicyVNext once (for AutoConfirm, not SingleOrg) + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should rollback SingleOrg policy when AutoConfirm fails and SingleOrg was enabled during action", async () => { + const autoConfirmError = new Error("AutoConfirm failed"); + + // First call (SingleOrg enable) succeeds, second call (AutoConfirm) fails, third call (SingleOrg rollback) succeeds + mockPolicyApiService.putPolicyVNext + .mockResolvedValueOnce({} as any) // SingleOrg enable + .mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails + .mockResolvedValueOnce({} as any); // SingleOrg rollback + + await expect(component["handleSubmit"](false)).rejects.toThrow("AutoConfirm failed"); + + // Verify: SingleOrg enabled, AutoConfirm attempted, SingleOrg rolled back + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(3); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 2, + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 3, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: false, data: null } }, + ); + }); + + it("should not rollback SingleOrg policy when AutoConfirm fails but SingleOrg was already enabled", async () => { + const autoConfirmError = new Error("AutoConfirm failed"); + + // AutoConfirm call fails (SingleOrg was already enabled, so no SingleOrg calls) + mockPolicyApiService.putPolicyVNext.mockRejectedValue(autoConfirmError); + + await expect(component["handleSubmit"](true)).rejects.toThrow("AutoConfirm failed"); + + // Verify only AutoConfirm was called (no SingleOrg enable/rollback) + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(1); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should keep both policies enabled when both submissions succeed", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["handleSubmit"](false); + + // Verify two calls: SingleOrg enable and AutoConfirm enable + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledTimes(2); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 1, + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenNthCalledWith( + 2, + mockOrgId, + PolicyType.AutoConfirm, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should re-throw the error after rollback", async () => { + const autoConfirmError = new Error("Network error"); + + mockPolicyApiService.putPolicyVNext + .mockResolvedValueOnce({} as any) // SingleOrg enable + .mockRejectedValueOnce(autoConfirmError) // AutoConfirm fails + .mockResolvedValueOnce({} as any); // SingleOrg rollback + + await expect(component["handleSubmit"](false)).rejects.toThrow("Network error"); + }); + }); + + describe("setSingleOrgPolicy", () => { + it("should call putPolicyVNext with enabled: true when enabling", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["setSingleOrgPolicy"](true); + + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: true, data: null } }, + ); + }); + + it("should call putPolicyVNext with enabled: false when disabling", async () => { + mockPolicyApiService.putPolicyVNext.mockResolvedValue({} as any); + + await component["setSingleOrgPolicy"](false); + + expect(mockPolicyApiService.putPolicyVNext).toHaveBeenCalledWith( + mockOrgId, + PolicyType.SingleOrg, + { policy: { enabled: false, data: null } }, + ); + }); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts index fbdeffc71bb..f0146225b8d 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-dialogs/auto-confirm-edit-policy-dialog.component.ts @@ -181,10 +181,21 @@ export class AutoConfirmPolicyDialogComponent } private async handleSubmit(singleOrgEnabled: boolean) { - if (!singleOrgEnabled) { - await this.submitSingleOrg(); + const enabledSingleOrgDuringAction = !singleOrgEnabled; + + if (enabledSingleOrgDuringAction) { + await this.setSingleOrgPolicy(true); + } + + try { + await this.submitAutoConfirm(); + } catch (error) { + // Roll back SingleOrg if we enabled it during this action + if (enabledSingleOrgDuringAction) { + await this.setSingleOrgPolicy(false); + } + throw error; } - await this.submitAutoConfirm(); } /** @@ -198,11 +209,9 @@ export class AutoConfirmPolicyDialogComponent const autoConfirmRequest = await this.policyComponent.buildRequest(); - await this.policyApiService.putPolicy( - this.data.organizationId, - this.data.policy.type, - autoConfirmRequest, - ); + await this.policyApiService.putPolicyVNext(this.data.organizationId, this.data.policy.type, { + policy: autoConfirmRequest, + }); const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); @@ -225,17 +234,15 @@ export class AutoConfirmPolicyDialogComponent } } - private async submitSingleOrg(): Promise { + private async setSingleOrgPolicy(enabled: boolean): Promise { const singleOrgRequest: PolicyRequest = { - enabled: true, + enabled, data: null, }; - await this.policyApiService.putPolicyVNext( - this.data.organizationId, - PolicyType.SingleOrg, - singleOrgRequest, - ); + await this.policyApiService.putPolicyVNext(this.data.organizationId, PolicyType.SingleOrg, { + policy: singleOrgRequest, + }); } private async openBrowserExtension() { From 42aec64689f47dd7ef9ea99bec14c74864870c88 Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 27 Jan 2026 13:31:02 -0500 Subject: [PATCH 039/130] [PM-16863] Update "auto-fill" to "autofill" for org policies (#18483) * Fixes typo in messages.json from auto-fill to autofill to match company preference * Strings have to be immutable as learned from Brandon. Trying to delete old key-value pair to see if that's possible * Fix my typo --- apps/web/src/locales/en/messages.json | 4 ++-- .../policy-edit-definitions/activate-autofill.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5a83bc75810..afb7c223f2c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6928,8 +6928,8 @@ "activateAutofill": { "message": "Activate auto-fill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, "experimentalFeature": { "message": "Compromised or untrusted websites can exploit auto-fill on page load." diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts index 08fe807f669..03eb189741c 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts @@ -12,7 +12,7 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; export class ActivateAutofillPolicy extends BasePolicyEditDefinition { name = "activateAutofill"; - description = "activateAutofillPolicyDesc"; + description = "activateAutofillPolicyDescription"; type = PolicyType.ActivateAutofill; component = ActivateAutofillPolicyComponent; From fe753c9c02d28ba104c71cacf175bbf69d666523 Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 27 Jan 2026 13:34:23 -0500 Subject: [PATCH 040/130] Add support for DuckDuckGo browser in event service (#18576) --- apps/web/src/app/core/event.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 36afd1850e0..47f4344ec36 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -722,6 +722,8 @@ export class EventService { return ["bwi-browser", this.i18nService.t("webVault") + " - Edge"]; case DeviceType.IEBrowser: return ["bwi-browser", this.i18nService.t("webVault") + " - IE"]; + case DeviceType.DuckDuckGoBrowser: + return ["bwi-browser", this.i18nService.t("webVault") + " - DuckDuckGo"]; case DeviceType.Server: return ["bwi-user-monitor", this.i18nService.t("server")]; case DeviceType.WindowsCLI: From 122bd9864309c7acbafebd5b56bd572493b41972 Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 27 Jan 2026 13:38:22 -0500 Subject: [PATCH 041/130] Refactor access tab label in collection dialog component to use a getter for improved readability and localization support. (#18537) --- .../collection-dialog/collection-dialog.component.html | 2 +- .../collection-dialog/collection-dialog.component.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index e509692aba7..431d7711331 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -63,7 +63,7 @@ - +
{{ "readOnlyCollectionAccess" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 4f40ea701d2..2f9ddddd8cb 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -361,6 +361,12 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { return this.params.readonly === true; } + protected get accessTabLabel(): string { + return this.dialogReadonly + ? this.i18nService.t("viewAccess") + : this.i18nService.t("editAccess"); + } + protected async cancel() { this.close(CollectionDialogAction.Canceled); } From 4ac38c18c0917e8ddfa8e39c2902add2df91276e Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 27 Jan 2026 13:39:34 -0500 Subject: [PATCH 042/130] [PM-27909] dialog improvements for claim domain (#18535) * Update domain status message from "Under verification" to "Pending" in localization and adjust corresponding template reference * Update domain status message from "Under verification" to "Pending" in the admin console template * Add domain verification instructions to the admin console dialog Enhanced the domain add/edit dialog by including detailed instructions for the automatic domain claim process when the domain is not verified. Removed the previous callout component for a more streamlined user experience. * Add new localization messages for automatic domain claim process Included detailed instructions for the automatic domain claim process, covering the steps for claiming a domain, account ownership change, and consequences of unclaimed domains. This enhances user guidance during domain management. * Refactor automatic domain claim process localization messages Updated localization keys for the automatic domain claim process to improve clarity and consistency. Removed redundant messages and streamlined the instructions displayed in the admin console dialog for better user experience. --- apps/web/src/locales/en/messages.json | 16 ++++++++-- .../domain-add-edit-dialog.component.html | 32 +++++++++++-------- .../domain-verification.component.html | 2 +- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index afb7c223f2c..9e210b6cb2e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11366,6 +11366,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11390,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index a2b231ffd48..80e76acac1d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -10,22 +10,34 @@ {{ "claimDomain" | i18n }} - - {{ data.orgDomain.domainName }} - - - {{ "domainStatusUnderVerification" | i18n }} + {{ "domainStatusPending" | i18n }} {{ "domainStatusClaimed" | i18n }}
+
+

{{ "automaticDomainClaimProcess1" | i18n }}

+

+ {{ "automaticDomainClaimProcess2" | i18n }} + + {{ "accountOwnershipChange" | i18n }} + + + {{ "automaticDomainClaimProcessEnd" | i18n }} +

+
{{ "domainName" | i18n }} - {{ "claimDomainNameInputHint" | i18n }} @@ -40,14 +52,6 @@ (click)="copyDnsTxt()" > - - - {{ "automaticDomainClaimProcess" | i18n }} -
@if (!decryptionFailure) { - + - + 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 d7de51ad20f..7a6c1db8026 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 @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, Input } from "@angular/core"; +import { booleanAttribute, Component, input, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -76,22 +76,10 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show view item menu option. Used when something else is - * assigned as the primary action for the item, such as autofill. - */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - showViewOption = false; - - /** - * Flag to hide the autofill menu options. Used for items that are + * Flag to show the autofill menu options. Used for items that are * already in the autofill list suggestion. */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) - hideAutofillOptions = false; + readonly showAutofill = input(false, { transform: booleanAttribute }); protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 3dac158b8e1..d3bc025905e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -90,11 +90,11 @@ - + + - + From a04566ae11e10db93972b52f86aec35aa5f7f8ce Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:25:10 -0500 Subject: [PATCH 051/130] chore(flags): [PM-31326] Rename ipc-channel-framework feature flag * Rename feature flag * Not sure what happened here. Renaming the class. --- .../src/platform/ipc/ipc-content-script-manager.service.ts | 2 +- libs/common/src/enums/feature-flag.enum.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts index e5fe95e2018..d53347b9dce 100644 --- a/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts +++ b/apps/browser/src/platform/ipc/ipc-content-script-manager.service.ts @@ -15,7 +15,7 @@ export class IpcContentScriptManagerService { } configService - .getFeatureFlag$(FeatureFlag.IpcChannelFramework) + .getFeatureFlag$(FeatureFlag.ContentScriptIpcChannelFramework) .pipe( mergeMap(async (enabled) => { if (!enabled) { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 819ae8bd8e2..35fa520f34a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -71,7 +71,7 @@ export enum FeatureFlag { PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk", /* Platform */ - IpcChannelFramework = "ipc-channel-framework", + ContentScriptIpcChannelFramework = "content-script-ipc-channel-framework", /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", @@ -162,7 +162,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE, /* Platform */ - [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.ContentScriptIpcChannelFramework]: FALSE, /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, From c2da621663b06b7a5279e730c17c8e1df87bc8c5 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Wed, 28 Jan 2026 09:31:55 -0500 Subject: [PATCH 052/130] [PM-28413] Remove feature flagged logic (#18566) * clean up flagged logic * fix test --- .../common/people-table-data-source.spec.ts | 12 +- .../common/people-table-data-source.ts | 25 +- .../members/deprecated_members.component.ts | 4 +- .../members/members.component.ts | 10 +- .../member-actions.service.spec.ts | 471 ++++++++---------- .../member-actions/member-actions.service.ts | 25 +- .../manage/deprecated_members.component.ts | 4 +- .../providers/manage/members.component.ts | 10 +- libs/common/src/enums/feature-flag.enum.ts | 2 - 9 files changed, 225 insertions(+), 338 deletions(-) diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts index e9cf87a114d..e174d01a75d 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from "@angular/core/testing"; import { ReplaySubject } from "rxjs"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Environment, EnvironmentService, @@ -46,23 +45,16 @@ describe("PeopleTableDataSource", () => { isCloud: () => false, } as Environment); - const mockConfigService = { - getFeatureFlag$: jest.fn(() => featureFlagSubject.asObservable()), - } as any; - const mockEnvironmentService = { environment$: environmentSubject.asObservable(), } as any; TestBed.configureTestingModule({ - providers: [ - { provide: ConfigService, useValue: mockConfigService }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, - ], + providers: [{ provide: EnvironmentService, useValue: mockEnvironmentService }], }); dataSource = TestBed.runInInjectionContext( - () => new TestPeopleTableDataSource(mockConfigService, mockEnvironmentService), + () => new TestPeopleTableDataSource(mockEnvironmentService), ); }); diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index d39a4f29653..a3ffbaeb7b5 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { computed, Signal } from "@angular/core"; +import { Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Observable, Subject, map } from "rxjs"; @@ -9,8 +9,6 @@ import { ProviderUserStatusType, } from "@bitwarden/common/admin-console/enums"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { TableDataSource } from "@bitwarden/components"; @@ -27,8 +25,7 @@ export type ProviderUser = ProviderUserUserDetailsResponse; export const MaxCheckedCount = 500; /** - * Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud - * feature flag is enabled on cloud environments. + * Maximum for bulk reinvite limit in cloud environments. */ export const CloudBulkReinviteLimit = 8000; @@ -78,18 +75,15 @@ export abstract class PeopleTableDataSource extends Tab confirmedUserCount: number; revokedUserCount: number; - /** True when increased bulk limit feature is enabled (feature flag + cloud environment) */ + /** True when increased bulk limit feature is enabled (cloud environment) */ readonly isIncreasedBulkLimitEnabled: Signal; - constructor(configService: ConfigService, environmentService: EnvironmentService) { + constructor(environmentService: EnvironmentService) { super(); - const featureFlagEnabled = toSignal( - configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + this.isIncreasedBulkLimitEnabled = toSignal( + environmentService.environment$.pipe(map((env) => env.isCloud())), ); - const isCloud = toSignal(environmentService.environment$.pipe(map((env) => env.isCloud()))); - - this.isIncreasedBulkLimitEnabled = computed(() => featureFlagEnabled() && isCloud()); } override set data(data: T[]) { @@ -224,12 +218,9 @@ export abstract class PeopleTableDataSource extends Tab } /** - * Gets checked users with optional limiting based on the IncreaseBulkReinviteLimitForCloud feature flag. + * Returns checked users in visible order, optionally limited to the specified count. * - * When the feature flag is enabled: Returns checked users in visible order, limited to the specified count. - * When the feature flag is disabled: Returns all checked users without applying any limit. - * - * @param limit The maximum number of users to return (only applied when feature flag is enabled) + * @param limit The maximum number of users to return * @returns The checked users array */ getCheckedUsersWithLimit(limit: number): T[] { diff --git a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts index 99fd81aa48d..93960820fbb 100644 --- a/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/deprecated_members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -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"; @@ -124,7 +123,6 @@ export class MembersComponent extends BaseMembersComponent private policyApiService: PolicyApiServiceAbstraction, private organizationMetadataService: OrganizationMetadataServiceAbstraction, private memberExportService: MemberExportService, - private configService: ConfigService, private environmentService: EnvironmentService, ) { super( @@ -139,7 +137,7 @@ export class MembersComponent extends BaseMembersComponent toastService, ); - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + this.dataSource = new MembersTableDataSource(this.environmentService); const organization$ = this.route.params.pipe( concatMap((params) => diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index 84b4cba7c2d..e3ed575d81b 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -33,7 +33,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; -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"; @@ -100,7 +99,6 @@ export class vNextMembersComponent { private policyService = inject(PolicyService); private policyApiService = inject(PolicyApiServiceAbstraction); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private memberExportService = inject(MemberExportService); @@ -114,7 +112,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: Signal = signal( - new MembersTableDataSource(this.configService, this.environmentService), + new MembersTableDataSource(this.environmentService), ); protected readonly organization: Signal; protected readonly firstLoaded: WritableSignal = signal(false); @@ -389,7 +387,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let filteredUsers: OrganizationUserView[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { filteredUsers = this.dataSource().limitAndUncheckExcess( @@ -418,7 +416,7 @@ export class vNextMembersComponent { this.validationService.showError(result.failed); } - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { const selectedCount = originalInvitedCount; const invitedCount = filteredUsers.length; @@ -441,7 +439,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog await this.memberDialogManager.openBulkStatusDialog( users, filteredUsers, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 1df285d7ba2..423977e73c4 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -17,7 +17,6 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -32,7 +31,6 @@ describe("MemberActionsService", () => { let service: MemberActionsService; let organizationUserApiService: MockProxy; let organizationUserService: MockProxy; - let configService: MockProxy; let organizationMetadataService: MockProxy; const organizationId = newGuid() as OrganizationId; @@ -44,7 +42,6 @@ describe("MemberActionsService", () => { beforeEach(() => { organizationUserApiService = mock(); organizationUserService = mock(); - configService = mock(); organizationMetadataService = mock(); mockOrganization = { @@ -68,7 +65,6 @@ describe("MemberActionsService", () => { MemberActionsService, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: OrganizationUserService, useValue: organizationUserService }, - { provide: ConfigService, useValue: configService }, { provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService, @@ -279,308 +275,247 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { - const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId]; + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { + const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - describe("when feature flag is false", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - }); + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - it("should successfully reinvite multiple users", async () => { - const mockResponse = new ListResponse( - { - data: userIds.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.failed).toEqual([]); - expect(result.successful).toBeDefined(); - expect(result.successful).toEqual(mockResponse); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIds, - ); - }); - - it("should handle bulk reinvite errors", async () => { - const errorMessage = "Bulk reinvite failed"; - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(3); - expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); - }); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(1); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdsBatch, + ); }); - describe("when feature flag is true (batching behavior)", () => { - beforeEach(() => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - }); - it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { - const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); - const mockResponse = new ListResponse( - { - data: userIdsBatch.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 1, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIdsBatch, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 1, + organizationId, + userIdsBatch.slice(0, REQUESTS_PER_BATCH), + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 2, + organizationId, + userIdsBatch.slice(REQUESTS_PER_BATCH), + ); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should aggregate results across multiple successful batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.failed).toHaveLength(0); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 1, - organizationId, - userIdsBatch.slice(0, REQUESTS_PER_BATCH), - ); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( - 2, - organizationId, - userIdsBatch.slice(REQUESTS_PER_BATCH), - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should aggregate results across multiple successful batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual(mockResponse1.data); + expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should handle mixed individual errors across multiple batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 4; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ + id, + error: index % 10 === 0 ? "Rate limit exceeded" : null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [ + { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, + ], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(totalUsers); - expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual( - mockResponse1.data, - ); - expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); - expect(result.failed).toHaveLength(0); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle mixed individual errors across multiple batches", async () => { - const totalUsers = REQUESTS_PER_BATCH + 4; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ - id, - error: index % 10 === 0 ? "Rate limit exceeded" : null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch + // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values + const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; + const expectedFailuresInBatch2 = 2; + const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; + const expectedSuccesses = totalUsers - expectedTotalFailures; - const mockResponse2 = new ListResponse( - { - data: [ - { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, - { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, - ], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.failed).toHaveLength(expectedTotalFailures); + expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); + expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); + expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); + }); - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + it("should aggregate all failures when all batches fail", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const errorMessage = "All batches failed"; - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); - // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch - // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values - const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; - const expectedFailuresInBatch2 = 2; - const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; - const expectedSuccesses = totalUsers - expectedTotalFailures; + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(expectedSuccesses); - expect(result.failed).toHaveLength(expectedTotalFailures); - expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); - expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); - expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); - }); + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(totalUsers); + expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); + }); - it("should aggregate all failures when all batches fail", async () => { - const totalUsers = REQUESTS_PER_BATCH + 100; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const errorMessage = "All batches failed"; + it("should handle empty data in batch response", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + const mockResponse2 = new ListResponse( + { + data: [], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(totalUsers); - expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); - it("should handle empty data in batch response", async () => { - const totalUsers = REQUESTS_PER_BATCH + 50; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); - const mockResponse1 = new ListResponse( - { - data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + }); - const mockResponse2 = new ListResponse( - { - data: [], - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); + it("should process batches sequentially in order", async () => { + const totalUsers = REQUESTS_PER_BATCH * 2; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const callOrder: number[] = []; - organizationUserApiService.postManyOrganizationUserReinvite - .mockResolvedValueOnce(mockResponse1) - .mockResolvedValueOnce(mockResponse2); + organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( + async (orgId, ids) => { + const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; + callOrder.push(batchIndex); - const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + return new ListResponse( + { + data: ids.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + }, + ); - expect(result.successful).toBeDefined(); - expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); - expect(result.failed).toHaveLength(0); - }); + await service.bulkReinvite(mockOrganization, userIdsBatch); - it("should process batches sequentially in order", async () => { - const totalUsers = REQUESTS_PER_BATCH * 2; - const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); - const callOrder: number[] = []; - - organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( - async (orgId, ids) => { - const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; - callOrder.push(batchIndex); - - return new ListResponse( - { - data: ids.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - }, - OrganizationUserBulkResponse, - ); - }, - ); - - await service.bulkReinvite(mockOrganization, userIdsBatch); - - expect(callOrder).toEqual([1, 2]); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( - 2, - ); - }); + expect(callOrder).toEqual([1, 2]); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 5833238209c..e8c4a21d675 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -16,9 +16,7 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; @@ -45,7 +43,6 @@ export interface BulkActionResult { export class MemberActionsService { private organizationUserApiService = inject(OrganizationUserApiService); private organizationUserService = inject(OrganizationUserService); - private configService = inject(ConfigService); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); private apiService = inject(ApiService); private dialogService = inject(DialogService); @@ -175,18 +172,9 @@ export class MemberActionsService { async bulkReinvite(organization: Organization, userIds: UserId[]): Promise { this.startProcessing(); try { - const increaseBulkReinviteLimitForCloud = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => + this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), ); - if (increaseBulkReinviteLimitForCloud) { - return await this.vNextBulkReinvite(organization, userIds); - } else { - const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, - userIds, - ); - return { successful: result, failed: [] }; - } } catch (error) { return { failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), @@ -196,15 +184,6 @@ export class MemberActionsService { } } - async vNextBulkReinvite( - organization: Organization, - userIds: UserId[], - ): Promise { - return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => - this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), - ); - } - allowResetPassword( orgUser: OrganizationUserView, organization: Organization, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts index 004b0a8f7c9..3c6e530b686 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/deprecated_members.component.ts @@ -19,7 +19,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -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"; @@ -85,7 +84,6 @@ export class MembersComponent extends BaseMembersComponent { private providerService: ProviderService, private router: Router, private accountService: AccountService, - private configService: ConfigService, private environmentService: EnvironmentService, ) { super( @@ -100,7 +98,7 @@ export class MembersComponent extends BaseMembersComponent { toastService, ); - this.dataSource = new MembersTableDataSource(this.configService, this.environmentService); + this.dataSource = new MembersTableDataSource(this.environmentService); combineLatest([ this.activatedRoute.parent.params, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts index d02b44af1be..a2330be4c6f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/members.component.ts @@ -21,7 +21,6 @@ import { Provider } from "@bitwarden/common/admin-console/models/domain/provider import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -72,7 +71,6 @@ export class vNextMembersComponent { private activatedRoute = inject(ActivatedRoute); private providerService = inject(ProviderService); private accountService = inject(AccountService); - private configService = inject(ConfigService); private environmentService = inject(EnvironmentService); private providerActionsService = inject(ProviderActionsService); private memberActionsService = inject(MemberActionsService); @@ -94,7 +92,7 @@ export class vNextMembersComponent { protected statusToggle = new BehaviorSubject(undefined); protected readonly dataSource: WritableSignal = signal( - new ProvidersTableDataSource(this.configService, this.environmentService), + new ProvidersTableDataSource(this.environmentService), ); protected readonly firstLoaded: WritableSignal = signal(false); @@ -177,7 +175,7 @@ export class vNextMembersComponent { // Capture the original count BEFORE enforcing the limit const originalInvitedCount = allInvitedUsers.length; - // When feature flag is enabled, limit invited users and uncheck the excess + // In cloud environments, limit invited users and uncheck the excess let checkedInvitedUsers: ProviderUser[]; if (this.dataSource().isIncreasedBulkLimitEnabled()) { checkedInvitedUsers = this.dataSource().limitAndUncheckExcess( @@ -198,7 +196,7 @@ export class vNextMembersComponent { } try { - // When feature flag is enabled, show toast instead of dialog + // In cloud environments, show toast instead of dialog if (this.dataSource().isIncreasedBulkLimitEnabled()) { await this.apiService.postManyProviderUserReinvite( providerId, @@ -226,7 +224,7 @@ export class vNextMembersComponent { }); } } else { - // Feature flag disabled - show legacy dialog + // In self-hosted environments, show legacy dialog const request = this.apiService.postManyProviderUserReinvite( providerId, new ProviderUserBulkRequest(checkedInvitedUsers.map((user) => user.id)), diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 35fa520f34a..244bd80d1fa 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,7 +13,6 @@ export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", - IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", /* Auth */ @@ -104,7 +103,6 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, - [FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ From 65b224646d87daa4794773cce5eefc815b71bd69 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Wed, 28 Jan 2026 09:32:02 -0500 Subject: [PATCH 053/130] Tools/pm 29918/implement send auth flows (#18270) * [PM-29918] Implement new Send auth flows * [PM-29918] Fix types * Trigger Claude code review * [PM-29918] Address PR review comments * [PM-29918] Remove duplicate AuthType const --- .../send/send-access/access.component.html | 3 +- .../send/send-access/access.component.ts | 37 ++-- .../send-access-email.component.html | 35 +++ .../send-access-email.component.ts | 35 +++ .../send-access-file.component.html | 4 +- .../send-access/send-access-file.component.ts | 40 ++-- .../send-access-password.component.html | 41 ++-- .../send-access-password.component.ts | 35 +-- .../send/send-access/send-auth.component.html | 48 +++-- .../send/send-access/send-auth.component.ts | 203 ++++++++++++++---- .../send/send-access/send-view.component.html | 83 +++---- .../send/send-access/send-view.component.ts | 97 +++++---- apps/web/src/locales/en/messages.json | 3 + .../services/send-api.service.abstraction.ts | 11 + .../tools/send/services/send-api.service.ts | 41 ++++ 15 files changed, 493 insertions(+), 223 deletions(-) create mode 100644 apps/web/src/app/tools/send/send-access/send-access-email.component.html create mode 100644 apps/web/src/app/tools/send/send-access/send-access-email.component.ts diff --git a/apps/web/src/app/tools/send/send-access/access.component.html b/apps/web/src/app/tools/send/send-access/access.component.html index b86933410b8..6cda4cf4d7d 100644 --- a/apps/web/src/app/tools/send/send-access/access.component.html +++ b/apps/web/src/app/tools/send/send-access/access.component.html @@ -1,4 +1,4 @@ -@switch (viewState) { +@switch (viewState()) { @case ("auth") { } @@ -6,6 +6,7 @@ (SendViewState.Auth); id: string; key: string; + sendAccessToken: SendAccessToken | null = null; sendAccessResponse: SendAccessResponse | null = null; sendAccessRequest: SendAccessRequest = new SendAccessRequest(); - constructor(private route: ActivatedRoute) {} + constructor( + private route: ActivatedRoute, + private destroyRef: DestroyRef, + ) {} - async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.params.subscribe(async (params) => { + ngOnInit() { + this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { this.id = params.sendId; this.key = params.key; - - if (this.id && this.key) { - this.viewState = SendViewState.View; - this.sendAccessResponse = null; - this.sendAccessRequest = new SendAccessRequest(); - } }); } onAuthRequired() { - this.viewState = SendViewState.Auth; + this.viewState.set(SendViewState.Auth); } - onAccessGranted(event: { response: SendAccessResponse; request: SendAccessRequest }) { + onAccessGranted(event: { + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; + }) { this.sendAccessResponse = event.response; this.sendAccessRequest = event.request; - this.viewState = SendViewState.View; + this.sendAccessToken = event.accessToken; + this.viewState.set(SendViewState.View); } } diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.html b/apps/web/src/app/tools/send/send-access/send-access-email.component.html new file mode 100644 index 00000000000..ee5a03670bb --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.html @@ -0,0 +1,35 @@ +@if (!enterOtp()) { + + {{ "email" | i18n }} + + +
+ +
+} @else { + + {{ "verificationCode" | i18n }} + + +
+ +
+} diff --git a/apps/web/src/app/tools/send/send-access/send-access-email.component.ts b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts new file mode 100644 index 00000000000..b1374cd6c66 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-email.component.ts @@ -0,0 +1,35 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; + +import { SharedModule } from "../../../shared"; + +@Component({ + selector: "app-send-access-email", + templateUrl: "send-access-email.component.html", + imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendAccessEmailComponent implements OnInit, OnDestroy { + protected readonly formGroup = input.required(); + protected readonly enterOtp = input.required(); + protected email: FormControl; + protected otp: FormControl; + + readonly loading = input.required(); + + constructor() {} + + ngOnInit() { + this.email = new FormControl("", Validators.required); + this.otp = new FormControl("", Validators.required); + this.formGroup().addControl("email", this.email); + this.formGroup().addControl("otp", this.otp); + } + + ngOnDestroy() { + this.formGroup().removeControl("email"); + this.formGroup().removeControl("otp"); + } +} diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.html b/apps/web/src/app/tools/send/send-access/send-access-file.component.html index 8cbe6a975ef..4088b3a7034 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.html @@ -1,5 +1,5 @@ -

{{ send.file.fileName }}

+

{{ send().file.fileName }}

diff --git a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts index dc7689f011a..bb45e83d110 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-file.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-file.component.ts @@ -1,8 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -15,40 +18,39 @@ import { ToastService } from "@bitwarden/components"; import { SharedModule } 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({ selector: "app-send-access-file", templateUrl: "send-access-file.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessFileComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() send: SendAccessView; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() decKey: SymmetricCryptoKey; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() accessRequest: SendAccessRequest; + readonly send = input(null); + readonly decKey = input(null); + readonly accessRequest = input(null); + readonly accessToken = input(null); + constructor( private i18nService: I18nService, private toastService: ToastService, private encryptService: EncryptService, private fileDownloadService: FileDownloadService, private sendApiService: SendApiService, + private configService: ConfigService, ) {} protected download = async () => { - if (this.send == null || this.decKey == null) { + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const accessToken = this.accessToken(); + const accessRequest = this.accessRequest(); + const authMissing = (sendEmailOtp && !accessToken) || (!sendEmailOtp && !accessRequest); + if (this.send() == null || this.decKey() == null || authMissing) { return; } - const downloadData = await this.sendApiService.getSendFileDownloadData( - this.send, - this.accessRequest, - ); + const downloadData = sendEmailOtp + ? await this.sendApiService.getSendFileDownloadDataV2(this.send(), accessToken) + : await this.sendApiService.getSendFileDownloadData(this.send(), accessRequest); if (Utils.isNullOrWhitespace(downloadData.url)) { this.toastService.showToast({ @@ -71,9 +73,9 @@ export class SendAccessFileComponent { try { const encBuf = await EncArrayBuffer.fromResponse(response); - const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey); + const decBuf = await this.encryptService.decryptFileData(encBuf, this.decKey()); this.fileDownloadService.download({ - fileName: this.send.file.fileName, + fileName: this.send().file.fileName, blobData: decBuf, downloadMethod: "save", }); diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.html b/apps/web/src/app/tools/send/send-access/send-access-password.component.html index 8bb2c306010..deca7ad3d24 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.html +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.html @@ -1,28 +1,19 @@

{{ "sendProtectedPassword" | i18n }}

{{ "sendProtectedPasswordDontKnow" | i18n }}

-
- - {{ "password" | i18n }} - - - -
- -
+ + {{ "password" | i18n }} + + + +
+
diff --git a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts index 34b183be10e..b2ee222ae86 100644 --- a/apps/web/src/app/tools/send/send-access/send-access-password.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-access-password.component.ts @@ -1,43 +1,30 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { SharedModule } 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({ selector: "app-send-access-password", templateUrl: "send-access-password.component.html", imports: [SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendAccessPasswordComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - protected formGroup = this.formBuilder.group({ - password: ["", [Validators.required]], - }); + protected readonly formGroup = input.required(); + protected password: FormControl; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading: boolean; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() setPasswordEvent = new EventEmitter(); + readonly loading = input.required(); - constructor(private formBuilder: FormBuilder) {} + constructor() {} - async ngOnInit() { - this.formGroup.controls.password.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((val) => { - this.setPasswordEvent.emit(val); - }); + ngOnInit() { + this.password = new FormControl("", Validators.required); + this.formGroup().addControl("password", this.password); } ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + this.formGroup().removeControl("password"); } } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.html b/apps/web/src/app/tools/send/send-access/send-auth.component.html index 21a6de50ba8..c3e90cea4ea 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.html +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.html @@ -1,14 +1,38 @@ -
-
-

{{ "sendAccessUnavailable" | i18n }}

+@if (loading()) { +
+ + {{ "loading" | i18n }}
-
-

{{ "unexpectedErrorSend" | i18n }}

-
- - +} + + @if (error()) { +
+

{{ "unexpectedErrorSend" | i18n }}

+
+ } + @if (unavailable()) { +
+

{{ "sendAccessUnavailable" | i18n }}

+
+ } @else { + @switch (sendAuthType()) { + @case (authType.Password) { + + } + @case (authType.Email) { + + } + } + } diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index b360044a8b6..13e82bd4cfa 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -1,86 +1,211 @@ -import { ChangeDetectionStrategy, Component, input, output } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, OnInit, output, signal } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; +import { + emailAndOtpRequiredEmailSent, + emailInvalid, + emailRequired, + otpInvalid, + passwordHashB64Invalid, + passwordHashB64Required, + SendAccessDomainCredentials, + SendAccessToken, + SendHashedPasswordB64, + sendIdInvalid, + SendOtp, + SendTokenService, +} from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; import { SEND_KDF_ITERATIONS } from "@bitwarden/common/tools/send/send-kdf"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; +import { SendAccessEmailComponent } from "./send-access-email.component"; import { SendAccessPasswordComponent } from "./send-access-password.component"; @Component({ selector: "app-send-auth", templateUrl: "send-auth.component.html", - imports: [SendAccessPasswordComponent, SharedModule], + imports: [SendAccessPasswordComponent, SendAccessEmailComponent, SharedModule], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SendAuthComponent { - readonly id = input.required(); - readonly key = input.required(); +export class SendAuthComponent implements OnInit { + protected readonly id = input.required(); + protected readonly key = input.required(); - accessGranted = output<{ - response: SendAccessResponse; - request: SendAccessRequest; + protected accessGranted = output<{ + response?: SendAccessResponse; + request?: SendAccessRequest; + accessToken?: SendAccessToken; }>(); - loading = false; - error = false; - unavailable = false; - password?: string; + authType = AuthType; - private accessRequest!: SendAccessRequest; + private expiredAuthAttempts = 0; + + readonly loading = signal(false); + readonly error = signal(false); + readonly unavailable = signal(false); + readonly sendAuthType = signal(AuthType.None); + readonly enterOtp = signal(false); + + sendAccessForm = this.formBuilder.group<{ password?: string; email?: string; otp?: string }>({}); constructor( private cryptoFunctionService: CryptoFunctionService, private sendApiService: SendApiService, private toastService: ToastService, private i18nService: I18nService, + private formBuilder: FormBuilder, + private configService: ConfigService, + private sendTokenService: SendTokenService, ) {} - async onSubmit(password: string) { - this.password = password; - this.loading = true; - this.error = false; - this.unavailable = false; + ngOnInit() { + void this.onSubmit(); + } + async onSubmit() { + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + if (sendEmailOtp) { + await this.attemptV2Access(); + } else { + await this.attemptV1Access(); + } + this.loading.set(false); + } + + private async attemptV1Access() { try { - const keyArray = Utils.fromUrlB64ToArray(this.key()); - this.accessRequest = new SendAccessRequest(); - - const passwordHash = await this.cryptoFunctionService.pbkdf2( - this.password, - keyArray, - "sha256", - SEND_KDF_ITERATIONS, - ); - this.accessRequest.password = Utils.fromBufferToB64(passwordHash); - - const sendResponse = await this.sendApiService.postSendAccess(this.id(), this.accessRequest); - this.accessGranted.emit({ response: sendResponse, request: this.accessRequest }); + const accessRequest = new SendAccessRequest(); + if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + accessRequest.password = await this.getPasswordHashB64(password, this.key()); + } + const sendResponse = await this.sendApiService.postSendAccess(this.id(), accessRequest); + this.accessGranted.emit({ request: accessRequest, response: sendResponse }); } catch (e) { if (e instanceof ErrorResponse) { - if (e.statusCode === 404) { - this.unavailable = true; - } else if (e.statusCode === 400) { + if (e.statusCode === 401) { + this.sendAuthType.set(AuthType.Password); + } else if (e.statusCode === 404) { + this.unavailable.set(true); + } else { + this.error.set(true); this.toastService.showToast({ variant: "error", title: this.i18nService.t("errorOccurred"), message: e.message, }); - } else { - this.error = true; } } else { - this.error = true; + this.error.set(true); } - } finally { - this.loading = false; } } + + private async attemptV2Access(): Promise { + let sendAccessCreds: SendAccessDomainCredentials | null = null; + if (this.sendAuthType() === AuthType.Email) { + const email = this.sendAccessForm.value.email; + if (email == null) { + return; + } + if (!this.enterOtp()) { + sendAccessCreds = { kind: "email", email }; + } else { + const otp = this.sendAccessForm.value.otp as SendOtp; + if (otp == null) { + return; + } + sendAccessCreds = { kind: "email_otp", email, otp }; + } + } else if (this.sendAuthType() === AuthType.Password) { + const password = this.sendAccessForm.value.password; + if (password == null) { + return; + } + const passwordHashB64 = await this.getPasswordHashB64(password, this.key()); + sendAccessCreds = { kind: "password", passwordHashB64 }; + } + const response = !sendAccessCreds + ? await firstValueFrom(this.sendTokenService.tryGetSendAccessToken$(this.id())) + : await firstValueFrom(this.sendTokenService.getSendAccessToken$(this.id(), sendAccessCreds)); + if (response instanceof SendAccessToken) { + this.expiredAuthAttempts = 0; + this.accessGranted.emit({ accessToken: response }); + } else if (response.kind === "expired") { + if (this.expiredAuthAttempts > 2) { + return; + } + this.expiredAuthAttempts++; + await this.attemptV2Access(); + } else if (response.kind === "expected_server") { + this.expiredAuthAttempts = 0; + if (emailRequired(response.error)) { + this.sendAuthType.set(AuthType.Email); + } else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) { + this.enterOtp.set(true); + } else if (otpInvalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidVerificationCode"), + }); + } else if (passwordHashB64Required(response.error)) { + this.sendAuthType.set(AuthType.Password); + } else if (passwordHashB64Invalid(response.error)) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidSendPassword"), + }); + } else if (sendIdInvalid(response.error)) { + this.unavailable.set(true); + } else { + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error.error_description ?? "", + }); + } + } else { + this.expiredAuthAttempts = 0; + this.error.set(true); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: response.error, + }); + } + } + + private async getPasswordHashB64(password: string, key: string) { + const keyArray = Utils.fromUrlB64ToArray(key); + const passwordHash = await this.cryptoFunctionService.pbkdf2( + password, + keyArray, + "sha256", + SEND_KDF_ITERATIONS, + ); + return Utils.fromBufferToB64(passwordHash) as SendHashedPasswordB64; + } } diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.html b/apps/web/src/app/tools/send/send-access/send-view.component.html index dd0b770b261..3536499ddad 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.html +++ b/apps/web/src/app/tools/send/send-access/send-view.component.html @@ -1,41 +1,13 @@ - - {{ "viewSendHiddenEmailWarning" | i18n }} - {{ - "learnMore" | i18n - }}. - +@if (hideEmail()) { + + {{ "viewSendHiddenEmailWarning" | i18n }} + {{ + "learnMore" | i18n + }} + +} - -
-

{{ "sendAccessUnavailable" | i18n }}

-
-
-

{{ "unexpectedErrorSend" | i18n }}

-
-
-

- {{ send.name }} -

-
- - - - - - - - -

- Expires: {{ expirationDate | date: "medium" }} -

-
-
- +@if (loading()) {
{{ "loading" | i18n }}
-
+} @else { + @if (unavailable()) { +
+

{{ "sendAccessUnavailable" | i18n }}

+
+ } + @if (error()) { +
+

{{ "unexpectedErrorSend" | i18n }}

+
+ } + @if (send()) { +
+

+ {{ send().name }} +

+
+ @switch (send().type) { + @case (sendType.Text) { + + } + @case (sendType.File) { + + } + } + @if (expirationDate()) { +

Expires: {{ expirationDate() | date: "medium" }}

+ } +
+ } +} diff --git a/apps/web/src/app/tools/send/send-access/send-view.component.ts b/apps/web/src/app/tools/send/send-access/send-view.component.ts index 060dc1958b1..1ab9a121ace 100644 --- a/apps/web/src/app/tools/send/send-access/send-view.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-view.component.ts @@ -1,13 +1,17 @@ import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, + computed, input, OnInit, output, + signal, } from "@angular/core"; +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -34,17 +38,25 @@ import { SendAccessTextComponent } from "./send-access-text.component"; export class SendViewComponent implements OnInit { readonly id = input.required(); readonly key = input.required(); + readonly accessToken = input(null); readonly sendResponse = input(null); readonly accessRequest = input(new SendAccessRequest()); authRequired = output(); - send: SendAccessView | null = null; + readonly send = signal(null); + readonly expirationDate = computed(() => this.send()?.expirationDate ?? null); + readonly creatorIdentifier = computed( + () => this.send()?.creatorIdentifier ?? null, + ); + readonly hideEmail = computed( + () => this.send() != null && this.creatorIdentifier() == null, + ); + readonly loading = signal(false); + readonly unavailable = signal(false); + readonly error = signal(false); + sendType = SendType; - loading = true; - unavailable = false; - error = false; - hideEmail = false; decKey!: SymmetricCryptoKey; constructor( @@ -53,50 +65,48 @@ export class SendViewComponent implements OnInit { private toastService: ToastService, private i18nService: I18nService, private layoutWrapperDataService: AnonLayoutWrapperDataService, - private cdRef: ChangeDetectorRef, + private configService: ConfigService, ) {} - get expirationDate() { - if (this.send == null || this.send.expirationDate == null) { - return null; - } - return this.send.expirationDate; - } - - get creatorIdentifier() { - if (this.send == null || this.send.creatorIdentifier == null) { - return null; - } - return this.send.creatorIdentifier; - } - - async ngOnInit() { - await this.load(); + ngOnInit() { + void this.load(); } private async load() { - this.unavailable = false; - this.error = false; - this.hideEmail = false; - this.loading = true; - - let response = this.sendResponse(); + this.loading.set(true); + this.unavailable.set(false); + this.error.set(false); try { - if (!response) { - response = await this.sendApiService.postSendAccess(this.id(), this.accessRequest()); + const sendEmailOtp = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + let response: SendAccessResponse; + if (sendEmailOtp) { + const accessToken = this.accessToken(); + if (!accessToken) { + this.authRequired.emit(); + return; + } + response = await this.sendApiService.postSendAccessV2(accessToken); + } else { + const sendResponse = this.sendResponse(); + if (!sendResponse) { + this.authRequired.emit(); + return; + } + response = sendResponse; } - const keyArray = Utils.fromUrlB64ToArray(this.key()); const sendAccess = new SendAccess(response); this.decKey = await this.keyService.makeSendKey(keyArray); - this.send = await sendAccess.decrypt(this.decKey); + const decSend = await sendAccess.decrypt(this.decKey); + this.send.set(decSend); } catch (e) { + this.send.set(null); if (e instanceof ErrorResponse) { if (e.statusCode === 401) { this.authRequired.emit(); } else if (e.statusCode === 404) { - this.unavailable = true; + this.unavailable.set(true); } else if (e.statusCode === 400) { this.toastService.showToast({ variant: "error", @@ -104,28 +114,23 @@ export class SendViewComponent implements OnInit { message: e.message, }); } else { - this.error = true; + this.error.set(true); } } else { - this.error = true; + this.error.set(true); } + } finally { + this.loading.set(false); } - this.loading = false; - this.hideEmail = - this.creatorIdentifier == null && !this.loading && !this.unavailable && !response; - - this.hideEmail = this.send != null && this.creatorIdentifier == null; - - if (this.creatorIdentifier != null) { + const creatorIdentifier = this.creatorIdentifier(); + if (creatorIdentifier != null) { this.layoutWrapperDataService.setAnonLayoutWrapperData({ pageSubtitle: { key: "sendAccessCreatorIdentifier", - placeholders: [this.creatorIdentifier], + placeholders: [creatorIdentifier], }, }); } - - this.cdRef.markForCheck(); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 932b58cf22a..a01e0b91e71 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12699,5 +12699,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 80c4410af11..a7e36d8c8b1 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ListResponse } from "../../../models/response/list.response"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; @@ -16,6 +18,10 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract getSends(): Promise>; abstract postSend(request: SendRequest): Promise; abstract postFileTypeSend(request: SendRequest): Promise; @@ -28,6 +34,11 @@ export abstract class SendApiService { request: SendAccessRequest, apiUrl?: string, ): Promise; + abstract getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise; abstract renewSendFileUploadUrl( sendId: string, fileId: string, diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index 1c931b7ad98..f09117316d8 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,3 +1,5 @@ +import { SendAccessToken } from "@bitwarden/common/auth/send-access"; + import { ApiService } from "../../../abstractions/api.service"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -52,6 +54,25 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendAccessResponse(r); } + async postSendAccessV2( + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access", + null, + false, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendAccessResponse(r); + } + async getSendFileDownloadData( send: SendAccessView, request: SendAccessRequest, @@ -72,6 +93,26 @@ export class SendApiService implements SendApiServiceAbstraction { return new SendFileDownloadDataResponse(r); } + async getSendFileDownloadDataV2( + send: SendAccessView, + accessToken: SendAccessToken, + apiUrl?: string, + ): Promise { + const setAuthTokenHeader = (headers: Headers) => { + headers.set("Authorization", "Bearer " + accessToken.token); + }; + const r = await this.apiService.send( + "POST", + "/sends/access/file/" + send.file.id, + null, + true, + true, + apiUrl, + setAuthTokenHeader, + ); + return new SendFileDownloadDataResponse(r); + } + async getSends(): Promise> { const r = await this.apiService.send("GET", "/sends", null, true, true); return new ListResponse(r, SendResponse); From 0138abf373fb0075207ee07bac071a9a47a4233d Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Wed, 28 Jan 2026 09:39:37 -0500 Subject: [PATCH 054/130] PM-29919 email verification on sends (#18260) * PM-29919 email verification on sends * PM-29919 resolved build issue * PM-29919 refined who can view fields * PM-29919 resolved lint issues * PM-29919 resolved lint issues * PM-29919 resolved unit tests * PM-29919 resolved lint issues * PM-29919 resolved unit test issue * PM-29919 resolved pr comments * PM-29919 resolved pr comments * PM-29919 resolved unneeded label * PM-29919 refactored to hide instead of disable * PM-29919 resolved pr comments * PM-29919 resolved no auth string in PM-31200 * PM-29919 resolved bugs --- apps/browser/src/_locales/en/messages.json | 48 +++-- apps/web/src/locales/en/messages.json | 24 +++ .../options/send-options.component.html | 72 ++----- .../options/send-options.component.spec.ts | 19 -- .../options/send-options.component.ts | 103 ++-------- .../send-details/send-details.component.html | 78 +++++++- .../send-details.component.spec.ts | 105 +++++++++- .../send-details/send-details.component.ts | 187 +++++++++++++++++- 8 files changed, 447 insertions(+), 189 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 8e2c3279687..4c36a852f6a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -4610,11 +4619,11 @@ "message": "URI match detection is how Bitwarden identifies autofill suggestions.", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, - "regExAdvancedOptionWarning": { + "regExAdvancedOptionWarning": { "message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'regular expression' matching strategy as a cipher match strategy" }, - "startsWithAdvancedOptionWarning": { + "startsWithAdvancedOptionWarning": { "message": "\"Starts with\" is an advanced option with increased risk of exposing credentials.", "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, @@ -4622,7 +4631,7 @@ "message": "More about match detection", "description": "Link to match detection docs on warning dialog for advance match strategy" }, - "uriAdvancedOption":{ + "uriAdvancedOption": { "message": "Advanced options", "description": "Advanced option placeholder for uri option component" }, @@ -4812,7 +4821,7 @@ } } }, - "copyFieldCipherName": { + "copyFieldCipherName": { "message": "Copy $FIELD$, $CIPHERNAME$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { @@ -4844,7 +4853,7 @@ "adminConsole": { "message": "Admin Console" }, - "admin" :{ + "admin": { "message": "Admin" }, "automaticUserConfirmation": { @@ -4853,7 +4862,7 @@ "automaticUserConfirmationHint": { "message": "Automatically confirm pending users while this device is unlocked" }, - "autoConfirmOnboardingCallout":{ + "autoConfirmOnboardingCallout": { "message": "Save time with automatic user confirmation" }, "autoConfirmWarning": { @@ -5793,7 +5802,7 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitleV2":{ + "phishingPageTitleV2": { "message": "Phishing attempt detected" }, "phishingPageSummary": { @@ -5813,7 +5822,7 @@ "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, - "phishingPageLearnMore" : { + "phishingPageLearnMore": { "message": "Learn more about phishing detection" }, "protectedBy": { @@ -5981,7 +5990,7 @@ "cardNumberLabel": { "message": "Card number" }, - "removeMasterPasswordForOrgUserKeyConnector":{ + "removeMasterPasswordForOrgUserKeyConnector": { "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." }, "continueWithLogIn": { @@ -5999,10 +6008,10 @@ "verifyYourOrganization": { "message": "Verify your organization to log in" }, - "organizationVerified":{ + "organizationVerified": { "message": "Organization verified" }, - "domainVerified":{ + "domainVerified": { "message": "Domain verified" }, "leaveOrganizationContent": { @@ -6120,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ 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 a01e0b91e71..3ba1ffc910b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -586,6 +586,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1368,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -12691,6 +12700,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index a271788b0ef..3f28ed289c9 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -7,64 +7,22 @@ {{ "limitSendViews" | i18n }} {{ "limitSendViewsHint" | i18n }} -  {{ "limitSendViewsCount" | i18n: viewsLeft }} + @if (shouldShowCount) { +  {{ "limitSendViewsCount" | i18n: viewsLeft }} + } - - {{ (passwordRemoved ? "newPassword" : "password") | i18n }} - - - - - - {{ "sendPasswordDescV3" | i18n }} - - - - {{ "hideYourEmail" | i18n }} - + + @if (!disableHideEmail || originalSendView?.hideEmail) { + + + {{ "hideYourEmail" | i18n }} + + } {{ "privateNote" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts index fa069b92ed2..47e8403f770 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.spec.ts @@ -5,12 +5,7 @@ import { of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { DialogService, ToastService } from "@bitwarden/components"; -import { CredentialGeneratorService } from "@bitwarden/generator-core"; import { SendFormContainer } from "../../send-form-container"; @@ -32,14 +27,9 @@ describe("SendOptionsComponent", () => { declarations: [], providers: [ { provide: SendFormContainer, useValue: mockSendFormContainer }, - { provide: DialogService, useValue: mock() }, - { provide: SendApiService, useValue: mock() }, { provide: PolicyService, useValue: mock() }, { provide: I18nService, useValue: mock() }, - { provide: ToastService, useValue: mock() }, - { provide: CredentialGeneratorService, useValue: mock() }, { provide: AccountService, useValue: mockAccountService }, - { provide: PlatformUtilsService, useValue: mock() }, ], }).compileComponents(); fixture = TestBed.createComponent(SendOptionsComponent); @@ -55,13 +45,4 @@ describe("SendOptionsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - it("should emit a null password when password textbox is empty", async () => { - const newSend = {} as SendView; - mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend)); - component.sendOptionsForm.patchValue({ password: "testing" }); - expect(newSend.password).toBe("testing"); - component.sendOptionsForm.patchValue({ password: "" }); - expect(newSend.password).toBe(null); - }); }); diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index ae8706a375e..a5f369d66aa 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -4,32 +4,26 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs"; +import { switchMap, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { + TypographyModule, AsyncActionsModule, ButtonModule, CardComponent, CheckboxModule, - DialogService, FormFieldModule, IconButtonModule, SectionComponent, SectionHeaderComponent, - ToastService, - TypographyModule, + SelectModule, } from "@bitwarden/components"; -import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -39,6 +33,7 @@ import { SendFormContainer } from "../../send-form-container"; @Component({ selector: "tools-send-options", templateUrl: "./send-options.component.html", + standalone: true, imports: [ AsyncActionsModule, ButtonModule, @@ -51,6 +46,7 @@ import { SendFormContainer } from "../../send-form-container"; ReactiveFormsModule, SectionComponent, SectionHeaderComponent, + SelectModule, TypographyModule, ], }) @@ -64,19 +60,14 @@ export class SendOptionsComponent implements OnInit { @Input() originalSendView: SendView; disableHideEmail = false; - passwordRemoved = false; + sendOptionsForm = this.formBuilder.group({ maxAccessCount: [null as number], accessCount: [null as number], notes: [null as string], - password: [null as string], hideEmail: [false as boolean], }); - get hasPassword(): boolean { - return this.originalSendView && this.originalSendView.password !== null; - } - get shouldShowCount(): boolean { return this.config.mode === "edit" && this.sendOptionsForm.value.maxAccessCount !== null; } @@ -91,13 +82,8 @@ export class SendOptionsComponent implements OnInit { constructor( private sendFormContainer: SendFormContainer, - private dialogService: DialogService, - private sendApiService: SendApiService, private formBuilder: FormBuilder, private policyService: PolicyService, - private i18nService: I18nService, - private toastService: ToastService, - private generatorService: CredentialGeneratorService, private accountService: AccountService, ) { this.sendFormContainer.registerChildForm("sendOptionsForm", this.sendOptionsForm); @@ -113,87 +99,28 @@ export class SendOptionsComponent implements OnInit { this.disableHideEmail = disableHideEmail; }); - this.sendOptionsForm.valueChanges - .pipe( - tap((value) => { - if (Utils.isNullOrWhitespace(value.password)) { - value.password = null; - } - }), - takeUntilDestroyed(), - ) - .subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - Object.assign(send, { - maxAccessCount: value.maxAccessCount, - accessCount: value.accessCount, - password: value.password, - hideEmail: value.hideEmail, - notes: value.notes, - }); - return send; + this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + Object.assign(send, { + maxAccessCount: value.maxAccessCount, + accessCount: value.accessCount, + hideEmail: value.hideEmail, + notes: value.notes, }); + return send; }); + }); } - generatePassword = async () => { - const on$ = new BehaviorSubject({ source: "send", type: Type.password }); - const account$ = this.accountService.activeAccount$.pipe( - pin({ name: () => "send-options.component", distinct: (p, c) => p.id === c.id }), - ); - const generatedCredential = await firstValueFrom( - this.generatorService.generate$({ on$, account$ }), - ); - - this.sendOptionsForm.patchValue({ - password: generatedCredential.credential, - }); - }; - - removePassword = async () => { - if (!this.originalSendView || !this.originalSendView.password) { - return; - } - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "removePassword" }, - content: { key: "removePasswordConfirmation" }, - type: "warning", - }); - - if (!confirmed) { - return false; - } - - this.passwordRemoved = true; - - await this.sendApiService.removePassword(this.originalSendView.id); - - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("removedPassword"), - }); - - this.originalSendView.password = null; - this.sendOptionsForm.patchValue({ - password: null, - }); - this.sendOptionsForm.get("password")?.enable(); - }; - ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ maxAccessCount: this.sendFormContainer.originalSendView.maxAccessCount, accessCount: this.sendFormContainer.originalSendView.accessCount, - password: this.hasPassword ? "************" : null, // 12 masked characters as a placeholder hideEmail: this.sendFormContainer.originalSendView.hideEmail, notes: this.sendFormContainer.originalSendView.notes, }); } - if (this.hasPassword) { - this.sendOptionsForm.get("password")?.disable(); - } if (!this.config.areSendsAllowed) { this.sendOptionsForm.disable(); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index e650ca3a5df..6d42cca2186 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -6,7 +6,7 @@ {{ "name" | i18n }} - + - + {{ "deletionDate" | i18n }} {{ "deletionDateDescV2" | i18n }} + + + {{ "whoCanView" | i18n }} + + @for (option of availableAuthTypes$ | async; track option.value) { + + } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + {{ "emailVerificationDesc" | i18n }} + } + + + @if (sendDetailsForm.get("authType").value === AuthType.Password) { + + {{ (passwordRemoved ? "newPassword" : "password") | i18n }} + +
+ @if (!hasPassword) { + + + + } @else { + + } +
+ {{ "sendPasswordDescV3" | i18n }} +
+ } + + @if (sendDetailsForm.get("authType").value === AuthType.Email) { + + {{ "emails" | i18n }} + + {{ "enterMultipleEmailsSeparatedByComma" | i18n }} + + } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts index 576842cd877..f816c9d5ce4 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.spec.ts @@ -1,4 +1,29 @@ -import { DatePreset, isDatePreset, asDatePreset } from "./send-details.component"; +import { DatePipe } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ReactiveFormsModule } from "@angular/forms"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +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 { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { CredentialGeneratorService } from "@bitwarden/generator-core"; + +import { SendFormContainer } from "../../send-form-container"; + +import { + DatePreset, + SendDetailsComponent, + asDatePreset, + isDatePreset, +} from "./send-details.component"; describe("SendDetails DatePreset utilities", () => { it("accepts all defined numeric presets", () => { @@ -25,3 +50,81 @@ describe("SendDetails DatePreset utilities", () => { }); }); }); + +describe("SendDetailsComponent", () => { + let component: SendDetailsComponent; + let fixture: ComponentFixture; + const mockSendFormContainer = mock(); + const mockI18nService = mock(); + const mockConfigService = mock(); + const mockAccountService = mock(); + const mockBillingStateService = mock(); + const mockGeneratorService = mock(); + const mockSendApiService = mock(); + const mockEnvironmentService = mock(); + + beforeEach(async () => { + mockEnvironmentService.environment$ = of({ + getSendUrl: () => "https://send.bitwarden.com/", + } as any); + mockAccountService.activeAccount$ = of({ id: "userId" } as Account); + mockConfigService.getFeatureFlag$.mockReturnValue(of(true)); + mockBillingStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + mockI18nService.t.mockImplementation((k) => k); + + await TestBed.configureTestingModule({ + imports: [SendDetailsComponent, ReactiveFormsModule], + providers: [ + { provide: SendFormContainer, useValue: mockSendFormContainer }, + { provide: I18nService, useValue: mockI18nService }, + { provide: DatePipe, useValue: new DatePipe("en-US") }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: BillingAccountProfileStateService, useValue: mockBillingStateService }, + { provide: CredentialGeneratorService, useValue: mockGeneratorService }, + { provide: SendApiService, useValue: mockSendApiService }, + { provide: PolicyService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendDetailsComponent); + component = fixture.componentInstance; + component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text }; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize authType to None if no password or emails", () => { + expect(component.sendDetailsForm.value.authType).toBe(AuthType.None); + }); + + it("should toggle validation based on authType", () => { + const emailsControl = component.sendDetailsForm.get("emails"); + const passwordControl = component.sendDetailsForm.get("password"); + + // Default + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Email + component.sendDetailsForm.patchValue({ authType: AuthType.Email }); + expect(emailsControl?.validator).not.toBeNull(); + expect(passwordControl?.validator).toBeNull(); + + // Select Password + component.sendDetailsForm.patchValue({ authType: AuthType.Password }); + expect(passwordControl?.validator).not.toBeNull(); + expect(emailsControl?.validator).toBeNull(); + + // Select None + component.sendDetailsForm.patchValue({ authType: AuthType.None }); + expect(emailsControl?.validator).toBeNull(); + expect(passwordControl?.validator).toBeNull(); + }); +}); diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index e2b50eafc99..463f3195645 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -3,13 +3,28 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component, OnInit, Input } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { + FormBuilder, + FormControl, + ReactiveFormsModule, + Validators, + ValidatorFn, + ValidationErrors, +} from "@angular/forms"; +import { firstValueFrom, BehaviorSubject, combineLatest, map, switchMap, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { pin } from "@bitwarden/common/tools/rx"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { SectionComponent, @@ -20,7 +35,12 @@ import { IconButtonModule, CheckboxModule, SelectModule, + AsyncActionsModule, + ButtonModule, + ToastService, + DialogService, } from "@bitwarden/components"; +import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; import { SendFormConfig } from "../../abstractions/send-form-config.service"; import { SendFormContainer } from "../../send-form-container"; @@ -78,6 +98,7 @@ export function asDatePreset(value: unknown): DatePreset | undefined { @Component({ selector: "tools-send-details", templateUrl: "./send-details.component.html", + standalone: true, imports: [ SectionComponent, SectionHeaderComponent, @@ -92,7 +113,10 @@ export function asDatePreset(value: unknown): DatePreset | undefined { IconButtonModule, CheckboxModule, CommonModule, + CommonModule, SelectModule, + AsyncActionsModule, + ButtonModule, ], }) export class SendDetailsComponent implements OnInit { @@ -105,31 +129,110 @@ export class SendDetailsComponent implements OnInit { FileSendType = SendType.File; TextSendType = SendType.Text; + readonly AuthType = AuthType; sendLink: string | null = null; customDeletionDateOption: DatePresetSelectOption | null = null; datePresetOptions: DatePresetSelectOption[] = []; + passwordRemoved = false; + + emailVerificationFeatureFlag$ = this.configService.getFeatureFlag$(FeatureFlag.SendEmailOTP); + hasPremium$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + authTypes: { name: string; value: AuthType; disabled?: boolean }[] = [ + { name: this.i18nService.t("noAuth"), value: AuthType.None }, + { name: this.i18nService.t("specificPeople"), value: AuthType.Email }, + { name: this.i18nService.t("anyOneWithPassword"), value: AuthType.Password }, + ]; + + availableAuthTypes$ = combineLatest([this.emailVerificationFeatureFlag$, this.hasPremium$]).pipe( + map(([enabled, hasPremium]) => { + if (!enabled || !hasPremium) { + return this.authTypes.filter((t) => t.value !== AuthType.Email); + } + return this.authTypes; + }), + ); sendDetailsForm = this.formBuilder.group({ name: new FormControl("", Validators.required), selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required), + authType: [AuthType.None as AuthType], + password: [null as string], + emails: [null as string], }); + get hasPassword(): boolean { + return this.originalSendView?.password != null; + } + constructor( protected sendFormContainer: SendFormContainer, protected formBuilder: FormBuilder, protected i18nService: I18nService, protected datePipe: DatePipe, protected environmentService: EnvironmentService, + private configService: ConfigService, + private accountService: AccountService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private generatorService: CredentialGeneratorService, + private sendApiService: SendApiService, + private dialogService: DialogService, + private toastService: ToastService, ) { - this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - return Object.assign(send, { - name: value.name, - deletionDate: new Date(this.formattedDeletionDate), - expirationDate: new Date(this.formattedDeletionDate), - } as SendView); + this.sendDetailsForm.valueChanges + .pipe( + tap((value) => { + if (Utils.isNullOrWhitespace(value.password)) { + value.password = null; + } + }), + takeUntilDestroyed(), + ) + .subscribe((value) => { + this.sendFormContainer.patchSend((send) => { + return Object.assign(send, { + name: value.name, + deletionDate: new Date(this.formattedDeletionDate), + expirationDate: new Date(this.formattedDeletionDate), + password: value.password, + emails: value.emails + ? value.emails + .split(",") + .map((e) => e.trim()) + .filter((e) => e.length > 0) + : null, + } as unknown as SendView); + }); + }); + + this.sendDetailsForm + .get("authType") + .valueChanges.pipe(takeUntilDestroyed()) + .subscribe((type) => { + const emailsControl = this.sendDetailsForm.get("emails"); + const passwordControl = this.sendDetailsForm.get("password"); + + if (type === AuthType.Password) { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValidators([Validators.required]); + } else if (type === AuthType.Email) { + passwordControl.setValue(null); + passwordControl.clearValidators(); + emailsControl.setValidators([Validators.required, this.emailListValidator()]); + } else { + emailsControl.setValue(null); + emailsControl.clearValidators(); + passwordControl.setValue(null); + passwordControl.clearValidators(); + } + emailsControl.updateValueAndValidity(); + passwordControl.updateValueAndValidity(); }); - }); this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm); } @@ -141,8 +244,15 @@ export class SendDetailsComponent implements OnInit { this.sendDetailsForm.patchValue({ name: this.originalSendView.name, selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(), + password: this.hasPassword ? "************" : null, + authType: this.originalSendView.authType, + emails: this.originalSendView.emails?.join(", ") ?? null, }); + if (this.hasPassword) { + this.sendDetailsForm.get("password")?.disable(); + } + if (this.originalSendView.deletionDate) { this.customDeletionDateOption = { name: this.datePipe.transform(this.originalSendView.deletionDate, "short"), @@ -193,4 +303,61 @@ export class SendDetailsComponent implements OnInit { const milliseconds = now.setTime(now.getTime() + preset * 60 * 60 * 1000); return new Date(milliseconds).toString(); } + + emailListValidator(): ValidatorFn { + return (control: FormControl): ValidationErrors | null => { + if (!control.value) { + return null; + } + const emails = control.value.split(",").map((e: string) => e.trim()); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e)); + return invalidEmails.length > 0 ? { email: true } : null; + }; + } + + generatePassword = async () => { + const on$ = new BehaviorSubject({ source: "send", type: Type.password }); + const account$ = this.accountService.activeAccount$.pipe( + pin({ name: () => "send-details.component", distinct: (p, c) => p.id === c.id }), + ); + const generatedCredential = await firstValueFrom( + this.generatorService.generate$({ on$, account$ }), + ); + + this.sendDetailsForm.patchValue({ + password: generatedCredential.credential, + }); + }; + + removePassword = async () => { + if (!this.originalSendView?.password) { + return; + } + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "removePassword" }, + content: { key: "removePasswordConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.passwordRemoved = true; + + await this.sendApiService.removePassword(this.originalSendView.id); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("removedPassword"), + }); + + this.originalSendView.password = null; + this.sendDetailsForm.patchValue({ + password: null, + }); + this.sendDetailsForm.get("password")?.enable(); + }; } From 23bd806d924de106ec2da776afe4a719f547c452 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Wed, 28 Jan 2026 10:10:39 -0500 Subject: [PATCH 055/130] Have AppSec own Checkmarx config (#18623) --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a768a9d51f6..b7fb098e662 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -221,6 +221,9 @@ apps/web/src/locales/en/messages.json **/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre **/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre +# Scanning tools +.checkmarx/ @bitwarden/team-appsec + ## Overrides # For the time being platform owns tsconfig and jest config # These overrides will be removed after Nx is implemented From 6d0f0b62f222a07a84836451b8741347017b1e0a Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 28 Jan 2026 10:24:06 -0500 Subject: [PATCH 056/130] [PM-31155] reorder policies in policies page (#18564) * Refactor policy edit registration to centralize ownership and improve organization. Reordered policies for clarity and added new policies for enhanced functionality. * Add PolicyOrderPipe for sorting policies and update policies component to utilize it * Add organizationDataOwnership to POLICY_ORDER_MAP for policy sorting * Fix PR comments --- .../organizations/policies/index.ts | 1 + .../policies/pipes/policy-order.pipe.ts | 66 +++++++++++++++++++ .../policies/policies.component.html | 2 +- .../policies/policies.component.ts | 3 +- 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts diff --git a/apps/web/src/app/admin-console/organizations/policies/index.ts b/apps/web/src/app/admin-console/organizations/policies/index.ts index eb614e180e1..8e730d3a6b8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/index.ts @@ -5,3 +5,4 @@ export { POLICY_EDIT_REGISTER } from "./policy-register-token"; export { AutoConfirmPolicy } from "./policy-edit-definitions"; export { PolicyEditDialogResult } from "./policy-edit-dialog.component"; export * from "./policy-edit-dialogs"; +export { PolicyOrderPipe } from "./pipes/policy-order.pipe"; diff --git a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts new file mode 100644 index 00000000000..ec9fef23b9d --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts @@ -0,0 +1,66 @@ +import { Pipe, PipeTransform } from "@angular/core"; + +import { BasePolicyEditDefinition } from "../base-policy-edit.component"; + +/** + * Order mapping for policies. Policies are ordered according to this mapping. + * Policies not in this mapping will appear at the end, maintaining their relative order. + */ +const POLICY_ORDER_MAP = new Map([ + ["singleOrg", 1], + ["organizationDataOwnership", 2], + ["centralizeDataOwnership", 2], + ["masterPassPolicyTitle", 3], + ["accountRecoveryPolicy", 4], + ["requireSso", 5], + ["automaticAppLoginWithSSO", 6], + ["twoStepLoginPolicyTitle", 7], + ["blockClaimedDomainAccountCreation", 8], + ["sessionTimeoutPolicyTitle", 9], + ["removeUnlockWithPinPolicyTitle", 10], + ["passwordGenerator", 11], + ["uriMatchDetectionPolicy", 12], + ["activateAutofill", 13], + ["sendOptions", 14], + ["disableSend", 15], + ["restrictedItemTypePolicy", 16], + ["freeFamiliesSponsorship", 17], + ["disableExport", 18], +]); + +/** + * Default order for policies not in the mapping. This ensures unmapped policies + * appear at the end while maintaining their relative order. + */ +const DEFAULT_ORDER = 999; + +@Pipe({ + name: "policyOrder", + standalone: true, +}) +export class PolicyOrderPipe implements PipeTransform { + transform( + policies: readonly BasePolicyEditDefinition[] | null | undefined, + ): BasePolicyEditDefinition[] { + if (policies == null || policies.length === 0) { + return []; + } + + const sortedPolicies = [...policies]; + + sortedPolicies.sort((a, b) => { + const orderA = POLICY_ORDER_MAP.get(a.name) ?? DEFAULT_ORDER; + const orderB = POLICY_ORDER_MAP.get(b.name) ?? DEFAULT_ORDER; + + if (orderA !== orderB) { + return orderA - orderB; + } + + const indexA = policies.indexOf(a); + const indexB = policies.indexOf(b); + return indexA - indexB; + }); + + return sortedPolicies; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.html b/apps/web/src/app/admin-console/organizations/policies/policies.component.html index c38092146ab..902c7e79d55 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.html @@ -15,7 +15,7 @@ } @else { - @for (p of policies$ | async; track $index) { + @for (p of policies$ | async | policyOrder; track $index) { @if (p.display$(organization, configService) | async) { diff --git a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts index 1f9a8deaa85..d13a2097628 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policies.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policies.component.ts @@ -21,13 +21,14 @@ import { HeaderModule } from "../../../layouts/header/header.module"; import { SharedModule } from "../../../shared"; import { BasePolicyEditDefinition, PolicyDialogComponent } from "./base-policy-edit.component"; +import { PolicyOrderPipe } from "./pipes/policy-order.pipe"; import { PolicyEditDialogComponent } from "./policy-edit-dialog.component"; import { PolicyListService } from "./policy-list.service"; import { POLICY_EDIT_REGISTER } from "./policy-register-token"; @Component({ templateUrl: "policies.component.html", - imports: [SharedModule, HeaderModule], + imports: [SharedModule, HeaderModule, PolicyOrderPipe], providers: [ safeProvider({ provide: PolicyListService, From 61225e6015acba5fdf6b357c8aa7cc5bad925dc7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:36:16 -0500 Subject: [PATCH 057/130] [deps]: Update actions/setup-node action to v6 (#17038) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-browser.yml | 6 +++--- .github/workflows/build-cli.yml | 4 ++-- .github/workflows/build-desktop.yml | 14 +++++++------- .github/workflows/chromatic.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/nx.yml | 2 +- .github/workflows/publish-cli.yml | 2 +- .github/workflows/sdk-breaking-change-check.yml | 2 +- .github/workflows/test.yml | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 7b35baf01e2..ef2c91f0a7d 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -152,7 +152,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -260,7 +260,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -392,7 +392,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index d0abe8e12e7..75820c54977 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -130,7 +130,7 @@ jobs: } >> "$GITHUB_ENV" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -326,7 +326,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 0d4009e54f9..c021dedd8e1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -183,7 +183,7 @@ jobs: uses: bitwarden/gh-actions/free-disk-space@main - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -339,7 +339,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -487,7 +487,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -755,7 +755,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1000,7 +1000,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1240,7 +1240,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1515,7 +1515,7 @@ jobs: persist-credentials: false - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c7d80b82baa..6189744fe67 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -58,7 +58,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6a5f6774474..7862c14c186 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -64,7 +64,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 3a7431c07f0..e468ead4f1e 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -26,7 +26,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ef287b0de08..5f6ee83e41f 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -216,7 +216,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} registry-url: "https://registry.npmjs.org/" diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index 765e900af5c..eab0dffeda4 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -76,7 +76,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eedf991d826..41b75c5a31d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - name: Set up Node - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' From 5dc49f21d2e5aa145fe9611222b4c0c5659db1c5 Mon Sep 17 00:00:00 2001 From: Will Martin Date: Wed, 28 Jan 2026 11:36:27 -0500 Subject: [PATCH 058/130] [CL-82] rename `bit-icon` to `bit-svg`; create new `bit-icon` component for font icons (#18584) * rename bit-icon to bit-svg; create new bit-icon for font icons Co-Authored-By: Claude Sonnet 4.5 * find and replace current usage Co-Authored-By: Claude Sonnet 4.5 * add custom eslint warning Co-Authored-By: Claude Sonnet 4.5 * fix incorrect usage * fix tests * fix tests * Update libs/components/src/svg/index.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * Update libs/eslint/components/no-bwi-class-usage.spec.mjs Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * update component api * update class name * use icon type in iconButton component * update type Icon --> BitSvg * fix bad renames * fix more renames * fix bad input * revert iconButton type * fix lint * fix more inputs * misc fixes Co-Authored-By: Claude Sonnet 4.5 * fix test * add eslint ignore * fix lint * add comparison story --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../popup/phishing-warning.component.ts | 4 +- .../popup-tab-navigation.component.html | 6 +- .../layout/popup-tab-navigation.component.ts | 10 +- ...tension-anon-layout-wrapper.component.html | 2 +- ...extension-anon-layout-wrapper.component.ts | 8 +- .../send-created/send-created.component.html | 2 +- .../send-created.component.spec.ts | 4 +- .../send-created/send-created.component.ts | 4 +- .../intro-carousel.component.html | 8 +- .../intro-carousel.component.ts | 4 +- .../credentials/fido2-create.component.html | 4 +- .../credentials/fido2-create.component.ts | 4 +- .../fido2-excluded-ciphers.component.html | 4 +- .../fido2-excluded-ciphers.component.ts | 4 +- .../credentials/fido2-vault.component.html | 2 +- .../credentials/fido2-vault.component.ts | 4 +- .../layouts/organization-layout.component.ts | 4 +- .../auto-confirm-policy.component.html | 2 +- .../accept-family-sponsorship.component.html | 4 +- .../accept-family-sponsorship.component.ts | 4 +- ...wo-factor-setup-authenticator.component.ts | 4 +- .../two-factor-setup-duo.component.ts | 4 +- .../two-factor-setup-email.component.ts | 4 +- .../create-credential-dialog.component.html | 4 +- ...nization-subscription-cloud.component.html | 2 +- .../subscription-hidden.component.ts | 2 +- .../shared/sm-subscribe.component.html | 2 +- .../reports/shared/models/report-entry.ts | 4 +- .../report-card/report-card.component.html | 2 +- .../report-card/report-card.component.ts | 4 +- .../shared/report-card/report-card.stories.ts | 4 +- .../shared/report-list/report-list.stories.ts | 4 +- .../app/layouts/header/web-header.stories.ts | 4 +- .../src/app/layouts/user-layout.component.ts | 4 +- .../onboarding/onboarding.stories.ts | 4 +- apps/web/src/app/shared/shared.module.ts | 6 +- .../send-success-drawer-dialog.component.html | 2 +- .../browser-extension-prompt.component.ts | 4 +- .../manually-open-extension.component.html | 6 +- .../manually-open-extension.component.ts | 4 +- .../setup-extension.component.html | 2 +- .../setup-extension.component.ts | 4 +- .../vault/individual-vault/vault.component.ts | 4 +- .../manage/accept-provider.component.html | 6 +- .../providers/providers-layout.component.ts | 8 +- .../setup/setup-provider.component.html | 6 +- .../setup/setup-business-unit.component.html | 6 +- .../empty-state-card.component.html | 12 +- .../empty-state-card.component.ts | 8 +- .../shared/org-suspended.component.ts | 4 +- eslint.config.mjs | 1 + .../components/two-factor-icon.component.html | 2 +- .../components/two-factor-icon.component.ts | 4 +- .../login-via-webauthn.component.ts | 4 +- libs/angular/src/jslib.module.ts | 6 +- libs/assets/README.md | 4 +- libs/assets/src/svg/icon-service.ts | 25 ---- libs/assets/src/svg/index.ts | 2 +- .../svg/{icon-service.spec.ts => svg.spec.ts} | 12 +- libs/assets/src/svg/svg.ts | 25 ++++ .../src/svg/svgs/account-warning.icon.ts | 4 +- libs/assets/src/svg/svgs/active-send.icon.ts | 4 +- libs/assets/src/svg/svgs/admin-console.ts | 4 +- libs/assets/src/svg/svgs/auto-confirmation.ts | 4 +- .../svg/svgs/background-left-illustration.ts | 4 +- .../svg/svgs/background-right-illustration.ts | 4 +- libs/assets/src/svg/svgs/bitwarden-icon.ts | 4 +- .../src/svg/svgs/bitwarden-logo.icon.ts | 4 +- libs/assets/src/svg/svgs/browser-extension.ts | 4 +- .../src/svg/svgs/business-unit-portal.ts | 4 +- .../src/svg/svgs/business-welcome.icon.ts | 4 +- libs/assets/src/svg/svgs/carousel-icon.ts | 4 +- libs/assets/src/svg/svgs/credit-card.icon.ts | 4 +- libs/assets/src/svg/svgs/deactivated-org.ts | 4 +- libs/assets/src/svg/svgs/devices.icon.ts | 4 +- libs/assets/src/svg/svgs/domain.icon.ts | 4 +- libs/assets/src/svg/svgs/empty-trash.ts | 4 +- libs/assets/src/svg/svgs/favorites.icon.ts | 4 +- libs/assets/src/svg/svgs/gear.ts | 4 +- libs/assets/src/svg/svgs/generator.ts | 6 +- libs/assets/src/svg/svgs/item-types.ts | 4 +- libs/assets/src/svg/svgs/lock.icon.ts | 4 +- libs/assets/src/svg/svgs/login-cards.ts | 4 +- .../src/svg/svgs/no-credentials.icon.ts | 4 +- libs/assets/src/svg/svgs/no-folders.ts | 4 +- libs/assets/src/svg/svgs/no-results.ts | 4 +- libs/assets/src/svg/svgs/no-send.icon.ts | 4 +- libs/assets/src/svg/svgs/party.ts | 4 +- libs/assets/src/svg/svgs/password-manager.ts | 4 +- libs/assets/src/svg/svgs/provider-portal.ts | 4 +- .../svg/svgs/registration-check-email.icon.ts | 4 +- .../svg/svgs/registration-user-add.icon.ts | 4 +- .../assets/src/svg/svgs/report-breach.icon.ts | 4 +- .../svg/svgs/report-exposed-passwords.icon.ts | 4 +- .../svgs/report-unsecured-websites.icon.ts | 4 +- libs/assets/src/svg/svgs/restricted-view.ts | 4 +- .../src/svg/svgs/secrets-manager-alt.ts | 4 +- libs/assets/src/svg/svgs/secrets-manager.ts | 4 +- libs/assets/src/svg/svgs/security.ts | 4 +- libs/assets/src/svg/svgs/send.ts | 6 +- libs/assets/src/svg/svgs/settings.ts | 6 +- libs/assets/src/svg/svgs/shield.ts | 4 +- libs/assets/src/svg/svgs/sso-key.icon.ts | 4 +- .../two-factor-auth-authenticator.icon.ts | 4 +- .../src/svg/svgs/two-factor-auth-duo.icon.ts | 8 +- .../svg/svgs/two-factor-auth-email.icon.ts | 4 +- ...wo-factor-auth-security-key-failed.icon.ts | 4 +- .../svgs/two-factor-auth-security-key.icon.ts | 4 +- .../svg/svgs/two-factor-auth-webauthn.icon.ts | 4 +- .../svg/svgs/two-factor-auth-yubico.icon.ts | 7 +- .../src/svg/svgs/two-factor-timeout.icon.ts | 4 +- libs/assets/src/svg/svgs/unlocked.icon.ts | 4 +- libs/assets/src/svg/svgs/user-lock.icon.ts | 4 +- ...erification-biometrics-fingerprint.icon.ts | 4 +- libs/assets/src/svg/svgs/vault-open.ts | 4 +- libs/assets/src/svg/svgs/vault.icon.ts | 4 +- libs/assets/src/svg/svgs/vault.ts | 6 +- libs/assets/src/svg/svgs/wave.icon.ts | 4 +- .../registration-link-expired.component.ts | 4 +- .../registration-start.component.ts | 4 +- .../two-factor-options.component.html | 36 +++--- .../two-factor-options.component.ts | 4 +- ...ser-verification-form-input.component.html | 2 +- .../user-verification-form-input.component.ts | 4 +- .../anon-layout-wrapper.component.ts | 6 +- .../src/anon-layout/anon-layout.component.ts | 8 +- .../src/anon-layout/anon-layout.stories.ts | 4 +- .../components/src/callout/callout.stories.ts | 4 +- libs/components/src/header/header.stories.ts | 4 +- libs/components/src/icon/icon.component.ts | 39 +++--- libs/components/src/icon/icon.mdx | 121 ++++-------------- libs/components/src/icon/icon.module.ts | 6 +- libs/components/src/icon/icon.stories.ts | 79 +++++++----- libs/components/src/icon/index.ts | 1 + libs/components/src/index.ts | 1 + .../landing-header.component.html | 2 +- .../landing-header.component.ts | 4 +- .../landing-hero.component.html | 2 +- .../landing-layout/landing-hero.component.ts | 8 +- .../landing-layout.component.html | 4 +- .../landing-layout.component.ts | 4 +- .../src/navigation/nav-logo.component.html | 2 +- .../src/navigation/nav-logo.component.ts | 8 +- .../src/no-items/no-items.component.html | 2 +- .../src/no-items/no-items.component.ts | 9 +- .../kitchen-sink-shared.module.ts | 6 +- libs/components/src/svg/index.ts | 2 + libs/components/src/svg/svg.component.ts | 31 +++++ .../svg.components.spec.ts} | 20 +-- libs/components/src/svg/svg.mdx | 120 +++++++++++++++++ libs/components/src/svg/svg.module.ts | 9 ++ libs/components/src/svg/svg.stories.ts | 50 ++++++++ libs/eslint/components/index.mjs | 2 + libs/eslint/components/no-bwi-class-usage.mjs | 45 +++++++ .../components/no-bwi-class-usage.spec.mjs | 44 +++++++ .../require-theme-colors-in-svg.mjs | 4 +- .../require-theme-colors-in-svg.spec.mjs | 14 +- .../pricing-card.component.spec.ts | 10 +- .../pricing-card/pricing-card.component.ts | 4 +- .../carousel-button.component.html | 2 +- .../carousel-button.component.ts | 4 +- 161 files changed, 764 insertions(+), 529 deletions(-) delete mode 100644 libs/assets/src/svg/icon-service.ts rename libs/assets/src/svg/{icon-service.spec.ts => svg.spec.ts} (69%) create mode 100644 libs/assets/src/svg/svg.ts create mode 100644 libs/components/src/svg/index.ts create mode 100644 libs/components/src/svg/svg.component.ts rename libs/components/src/{icon/icon.components.spec.ts => svg/svg.components.spec.ts} (55%) create mode 100644 libs/components/src/svg/svg.mdx create mode 100644 libs/components/src/svg/svg.module.ts create mode 100644 libs/components/src/svg/svg.stories.ts create mode 100644 libs/eslint/components/no-bwi-class-usage.mjs create mode 100644 libs/eslint/components/no-bwi-class-usage.spec.mjs diff --git a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts index d8e9895237c..419de04d9f4 100644 --- a/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/popup/phishing-warning.component.ts @@ -10,7 +10,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, IconTileComponent, LinkModule, CalloutComponent, @@ -31,7 +31,7 @@ import { templateUrl: "phishing-warning.component.html", imports: [ CommonModule, - IconModule, + SvgModule, JslibModule, LinkModule, FormFieldModule, diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index bce2b5033ae..e04d302ea2c 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -18,11 +18,11 @@ type="button" role="link" > - + > {{ button.label | i18n }} diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 26138d57954..5a40b72daff 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core"; import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { IconModule, LinkModule } from "@bitwarden/components"; +import { SvgModule, LinkModule } from "@bitwarden/components"; export type NavButton = { label: string; page: string; - icon: Icon; - iconActive: Icon; + icon: BitSvg; + iconActive: BitSvg; showBerry?: boolean; }; @@ -20,7 +20,7 @@ export type NavButton = { @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", - imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule], host: { class: "tw-block tw-size-full tw-flex tw-flex-col", }, diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index 484f9680519..2cf1998bb05 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -6,7 +6,7 @@ [pageTitle]="''" >
- +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 3a50f03e982..e07e9c50554 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -5,10 +5,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, switchMap, takeUntil, tap } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { - IconModule, + SvgModule, Translation, AnonLayoutComponent, AnonLayoutWrapperData, @@ -38,7 +38,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { CommonModule, CurrentAccountComponent, I18nPipe, - IconModule, + SvgModule, PopOutComponent, PopupPageComponent, PopupHeaderComponent, @@ -54,7 +54,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected pageTitle: string; protected pageSubtitle: string; - protected pageIcon: Icon; + protected pageIcon: BitSvg; protected showReadonlyHostname: boolean; protected maxWidth: "md" | "3xl"; protected hasLoggedInAccount: boolean = false; diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 828c1667c57..94c1df46eea 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -14,7 +14,7 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5" >
- +

{{ "createdSendSuccessfully" | i18n }} diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 521d72bba0c..a19897b6bbc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -14,7 +14,7 @@ import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/defau import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; -import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, I18nMockService, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -76,7 +76,7 @@ describe("SendCreatedComponent", () => { RouterTestingModule, JslibModule, ButtonModule, - IconModule, + SvgModule, PopOutComponent, PopupHeaderComponent, PopupPageComponent, diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index e9109ec6c21..e3717075e24 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -13,7 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, IconModule, ToastService } from "@bitwarden/components"; +import { ButtonModule, SvgModule, ToastService } from "@bitwarden/components"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; @@ -34,7 +34,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupPageComponent, RouterModule, PopupFooterComponent, - IconModule, + SvgModule, ], }) export class SendCreatedComponent { diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html index 5f19092d6b0..1980e8aa356 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.html @@ -2,7 +2,7 @@
- +

{{ "securityPrioritized" | i18n }}

{{ "securityPrioritizedBody" | i18n }}

@@ -11,7 +11,7 @@
- +

{{ "quickLogin" | i18n }}

{{ "quickLoginBody" | i18n }}

@@ -20,7 +20,7 @@
- +

{{ "secureUser" | i18n }}

{{ "secureUserBody" | i18n }}

@@ -29,7 +29,7 @@
- +

{{ "secureDevices" | i18n }}

{{ "secureDevicesBody" | i18n }}

diff --git a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts index 48c8f5682bc..5ad44c2f545 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/intro-carousel/intro-carousel.component.ts @@ -3,7 +3,7 @@ import { Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ItemTypes, LoginCards, NoCredentialsIcon, DevicesIcon } from "@bitwarden/assets/svg"; -import { ButtonModule, DialogModule, IconModule, TypographyModule } from "@bitwarden/components"; +import { ButtonModule, DialogModule, SvgModule, TypographyModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselModule } from "@bitwarden/vault"; @@ -17,7 +17,7 @@ import { IntroCarouselService } from "../../../services/intro-carousel.service"; imports: [ VaultCarouselModule, ButtonModule, - IconModule, + SvgModule, DialogModule, TypographyModule, JslibModule, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html index 67fc76aa317..4d3748d4303 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html @@ -5,7 +5,7 @@ >
- +

{{ "savePasskeyQuestion" | i18n }} @@ -28,7 +28,7 @@
- +
{{ "noMatchingLoginsForSite" | i18n }}
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts index 67237bedccd..d18fb6752e3 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts @@ -16,7 +16,7 @@ import { BadgeModule, ButtonModule, DialogModule, - IconModule, + SvgModule, ItemModule, SectionComponent, TableModule, @@ -42,7 +42,7 @@ import { BitIconButtonComponent, TableModule, JslibModule, - IconModule, + SvgModule, ButtonModule, DialogModule, SectionComponent, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html index 792934deedc..817c79eba3a 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html @@ -5,7 +5,7 @@ >
- +

{{ "savePasskeyQuestion" | i18n }} @@ -30,7 +30,7 @@ class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-start tw-items-center tw-gap-2 tw-h-full tw-px-5" >
- +
{{ "passkeyAlreadyExists" | i18n }} {{ "applicationDoesNotSupportDuplicates" | i18n }} diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts index 049771c2252..274956be0eb 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts @@ -9,7 +9,7 @@ import { BadgeModule, ButtonModule, DialogModule, - IconModule, + SvgModule, ItemModule, SectionComponent, TableModule, @@ -32,7 +32,7 @@ import { BitIconButtonComponent, TableModule, JslibModule, - IconModule, + SvgModule, ButtonModule, DialogModule, SectionComponent, diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html index ed04993d09f..df9458d8b14 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html @@ -5,7 +5,7 @@ >
- +

{{ "passkeyLogin" | i18n }}

diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts index 897e825c53e..635ba3972cb 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts @@ -24,7 +24,7 @@ import { ButtonModule, DialogModule, DialogService, - IconModule, + SvgModule, ItemModule, SectionComponent, TableModule, @@ -48,7 +48,7 @@ import { BitIconButtonComponent, TableModule, JslibModule, - IconModule, + SvgModule, ButtonModule, DialogModule, SectionComponent, diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index b00e4d9840d..2d1fde10856 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -26,7 +26,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { getById } from "@bitwarden/common/platform/misc"; -import { BannerModule, IconModule } from "@bitwarden/components"; +import { BannerModule, SvgModule } from "@bitwarden/components"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { NonIndividualSubscriber } from "@bitwarden/web-vault/app/billing/types"; @@ -47,7 +47,7 @@ import { WebLayoutModule } from "../../../layouts/web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, OrgSwitcherComponent, BannerModule, TaxIdWarningComponent, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index 54f166b662e..a8e3236dad8 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -44,7 +44,7 @@
- +
  1. 1. {{ "autoConfirmExtension1" | i18n }}
  2. diff --git a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html index ca1264829b9..0255e1a6a99 100644 --- a/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html +++ b/apps/web/src/app/admin-console/organizations/sponsorships/accept-family-sponsorship.component.html @@ -1,7 +1,7 @@
    - - + +
    - +

    {{ "creatingPasskeyLoading" | i18n }}

    {{ "creatingPasskeyLoadingInfo" | i18n }}

    @@ -27,7 +27,7 @@ class="tw-flex tw-flex-col tw-items-center" >
    - +

    {{ "errorCreatingPasskey" | i18n }}

    {{ "errorCreatingPasskeyInfo" | i18n }}

    diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 4858deabec6..496ddb4ff9b 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -242,7 +242,7 @@
    - +

    {{ "billingManagedByProvider" | i18n: userOrg.providerName }}

    {{ "billingContactProviderForAssistance" | i18n }}

    diff --git a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts index ef6e2dd0495..249cf999305 100644 --- a/apps/web/src/app/billing/organizations/subscription-hidden.component.ts +++ b/apps/web/src/app/billing/organizations/subscription-hidden.component.ts @@ -10,7 +10,7 @@ import { GearIcon } from "@bitwarden/assets/svg"; selector: "app-org-subscription-hidden", template: `
    - +

    {{ "billingManagedByProvider" | i18n: providerName }}

    {{ "billingContactProviderForAssistance" | i18n }}

    diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.html b/apps/web/src/app/billing/shared/sm-subscribe.component.html index 6cdaeb9476d..70990d2ee4c 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.html +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.html @@ -2,7 +2,7 @@

    {{ "moreFromBitwarden" | i18n }}

    - +
    - +
    diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 87c005ea46b..2f4934381b9 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, Input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { ReportVariant } from "../models/report-variant"; @@ -25,7 +25,7 @@ export class ReportCardComponent { @Input() route: string; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: Icon; + @Input() icon: BitSvg; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() variant: ReportVariant; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 93ea79c8418..4f442dc9380 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -14,7 +14,7 @@ import { BaseCardComponent, CardContentComponent, I18nMockService, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, CardContentComponent, - IconModule, + SvgModule, RouterTestingModule, PremiumBadgeComponent, BaseCardComponent, diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a95e332816..9686644bd74 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -12,7 +12,7 @@ import { BadgeModule, BaseCardComponent, CardContentComponent, - IconModule, + SvgModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -31,7 +31,7 @@ export default { JslibModule, BadgeModule, RouterTestingModule, - IconModule, + SvgModule, PremiumBadgeComponent, CardContentComponent, BaseCardComponent, diff --git a/apps/web/src/app/layouts/header/web-header.stories.ts b/apps/web/src/app/layouts/header/web-header.stories.ts index 88c98f01e6c..3b3b28b8e45 100644 --- a/apps/web/src/app/layouts/header/web-header.stories.ts +++ b/apps/web/src/app/layouts/header/web-header.stories.ts @@ -24,7 +24,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -94,7 +94,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, TabsModule, diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 90207f59ad4..33bce661c65 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -16,7 +16,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { BillingFreeFamiliesNavItemComponent } from "../billing/shared/billing-free-families-nav-item.component"; @@ -32,7 +32,7 @@ import { WebLayoutModule } from "./web-layout.module"; RouterModule, JslibModule, WebLayoutModule, - IconModule, + SvgModule, BillingFreeFamiliesNavItemComponent, ], }) diff --git a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts index 6d051a91f7e..6873700e2bc 100644 --- a/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts +++ b/apps/web/src/app/shared/components/onboarding/onboarding.stories.ts @@ -4,7 +4,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/an import { delay, of, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components"; +import { LinkModule, SvgModule, ProgressModule } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../core/tests"; @@ -16,7 +16,7 @@ export default { component: OnboardingComponent, decorators: [ moduleMetadata({ - imports: [JslibModule, RouterModule, LinkModule, IconModule, ProgressModule], + imports: [JslibModule, RouterModule, LinkModule, SvgModule, ProgressModule], declarations: [OnboardingTaskComponent], }), applicationConfig({ diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 6012e4867e1..b83555fd84e 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -18,7 +18,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -63,7 +63,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, @@ -99,7 +99,7 @@ import { DialogModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, MenuModule, MultiSelectModule, diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index a484f210f62..90210df4658 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -8,7 +8,7 @@ >
    - +
    diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index 54d62b8414a..51603724c57 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute } from "@angular/router"; import { map, Observable, of, tap } from "rxjs"; import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum"; -import { ButtonComponent, IconModule } from "@bitwarden/components"; +import { ButtonComponent, SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -24,7 +24,7 @@ import { ManuallyOpenExtensionComponent } from "../manually-open-extension/manua @Component({ selector: "vault-browser-extension-prompt", templateUrl: "./browser-extension-prompt.component.html", - imports: [CommonModule, I18nPipe, ButtonComponent, IconModule, ManuallyOpenExtensionComponent], + imports: [CommonModule, I18nPipe, ButtonComponent, SvgModule, ManuallyOpenExtensionComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html index d15cdaa712b..7da964f5fdb 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.html @@ -1,8 +1,8 @@

    {{ "openExtensionFromToolbarPart1" | i18n }} - + > {{ "openExtensionFromToolbarPart2" | i18n }}

    diff --git a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts index 435e847f6e9..e4db0a55097 100644 --- a/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts +++ b/apps/web/src/app/vault/components/manually-open-extension/manually-open-extension.component.ts @@ -1,14 +1,14 @@ import { Component, ChangeDetectionStrategy } from "@angular/core"; import { BitwardenIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "vault-manually-open-extension", templateUrl: "./manually-open-extension.component.html", - imports: [I18nPipe, IconModule], + imports: [I18nPipe, SvgModule], }) export class ManuallyOpenExtensionComponent { protected BitwardenIcon = BitwardenIcon; diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html index 8cfd394b854..d8cd562ac61 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.html @@ -31,7 +31,7 @@
    - +

    {{ diff --git a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts index cfc1961c4d8..1b2c0144549 100644 --- a/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts +++ b/apps/web/src/app/vault/components/setup-extension/setup-extension.component.ts @@ -18,7 +18,7 @@ import { CenterPositionStrategy, DialogRef, DialogService, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -52,7 +52,7 @@ type SetupExtensionState = UnionOfValues; JslibModule, ButtonComponent, LinkModule, - IconModule, + SvgModule, RouterModule, AddExtensionVideosComponent, ManuallyOpenExtensionComponent, 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 532757852a3..b07de88baf9 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -33,7 +33,7 @@ import { EmptyTrash, FavoritesIcon, ItemTypes, - Icon, + BitSvg, } from "@bitwarden/assets/svg"; import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -160,7 +160,7 @@ type EmptyStateType = "trash" | "favorites" | "archive"; type EmptyStateItem = { title: string; description: string; - icon: Icon; + icon: BitSvg; }; type EmptyStateMap = Record; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html index bc209ead2bd..1a1dd5b1bbb 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/accept-provider.component.html @@ -1,10 +1,10 @@
    - + >

    (); protected provider$: Observable; - protected logo$: Observable; + protected logo$: Observable; protected canAccessBilling$: Observable; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html index cb8eaea80c3..ff148098cc6 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup-provider.component.html @@ -1,10 +1,10 @@

    - + >

    - + >

    - + >

    }
    @@ -94,11 +94,11 @@
    - + >
    }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts index 54d97e984ec..c28de5e9952 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -1,17 +1,17 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; -import { ButtonModule, IconModule } from "@bitwarden/components"; +import { BitSvg } from "@bitwarden/assets/svg"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; @Component({ selector: "empty-state-card", templateUrl: "./empty-state-card.component.html", - imports: [CommonModule, IconModule, ButtonModule], + imports: [CommonModule, SvgModule, ButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyStateCardComponent implements OnInit { - readonly icon = input(null); + readonly icon = input(null); readonly videoSrc = input(null); readonly title = input(""); readonly description = input(""); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts index f2e0d48fe1d..241f02fce7e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, concatMap, firstValueFrom } from "rxjs"; -import { Icon, DeactivatedOrg } from "@bitwarden/assets/svg"; +import { BitSvg, DeactivatedOrg } from "@bitwarden/assets/svg"; import { getOrganizationById, OrganizationService, @@ -23,7 +23,7 @@ export class OrgSuspendedComponent { private route: ActivatedRoute, ) {} - protected DeactivatedOrg: Icon = DeactivatedOrg; + protected DeactivatedOrg: BitSvg = DeactivatedOrg; protected organizationName$ = this.route.params.pipe( concatMap(async (params) => { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); diff --git a/eslint.config.mjs b/eslint.config.mjs index e8f43d4a9ea..974aaafeef6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -207,6 +207,7 @@ export default tseslint.config( "error", { ignoreIfHas: ["bitPasswordInputToggle"] }, ], + "@bitwarden/components/no-bwi-class-usage": "warn", }, }, diff --git a/libs/angular/src/auth/components/two-factor-icon.component.html b/libs/angular/src/auth/components/two-factor-icon.component.html index 14558700757..555176225af 100644 --- a/libs/angular/src/auth/components/two-factor-icon.component.html +++ b/libs/angular/src/auth/components/two-factor-icon.component.html @@ -1,6 +1,6 @@
    - +
    0) { - throw new DynamicContentNotAllowedError(); - } - - return new Icon(strings[0]); -} diff --git a/libs/assets/src/svg/index.ts b/libs/assets/src/svg/index.ts index 9f86a14f772..6a0fff490ff 100644 --- a/libs/assets/src/svg/index.ts +++ b/libs/assets/src/svg/index.ts @@ -1,2 +1,2 @@ export * from "./svgs"; -export * from "./icon-service"; +export * from "./svg"; diff --git a/libs/assets/src/svg/icon-service.spec.ts b/libs/assets/src/svg/svg.spec.ts similarity index 69% rename from libs/assets/src/svg/icon-service.spec.ts rename to libs/assets/src/svg/svg.spec.ts index 2561c85aefa..2d8401f0b5d 100644 --- a/libs/assets/src/svg/icon-service.spec.ts +++ b/libs/assets/src/svg/svg.spec.ts @@ -1,5 +1,5 @@ -import * as IconExports from "./icon-service"; -import { DynamicContentNotAllowedError, isIcon, svgIcon } from "./icon-service"; +import * as IconExports from "./svg"; +import { DynamicContentNotAllowedError, isBitSvg, svg } from "./svg"; describe("Icon", () => { it("exports should not expose Icon class", () => { @@ -8,13 +8,13 @@ describe("Icon", () => { describe("isIcon", () => { it("should return true when input is icon", () => { - const result = isIcon(svgIcon`icon`); + const result = isBitSvg(svg`icon`); expect(result).toBe(true); }); it("should return false when input is not an icon", () => { - const result = isIcon({ svg: "not an icon" }); + const result = isBitSvg({ svg: "not an icon" }); expect(result).toBe(false); }); @@ -24,13 +24,13 @@ describe("Icon", () => { it("should throw when attempting to create dynamic icons", () => { const dynamic = "some user input"; - const f = () => svgIcon`static and ${dynamic}`; + const f = () => svg`static and ${dynamic}`; expect(f).toThrow(DynamicContentNotAllowedError); }); it("should return svg content when supplying icon with svg string", () => { - const icon = svgIcon`safe static content`; + const icon = svg`safe static content`; expect(icon.svg).toBe("safe static content"); }); diff --git a/libs/assets/src/svg/svg.ts b/libs/assets/src/svg/svg.ts new file mode 100644 index 00000000000..71324ea4bac --- /dev/null +++ b/libs/assets/src/svg/svg.ts @@ -0,0 +1,25 @@ +class BitSvg { + constructor(readonly svg: string) {} +} + +// We only export the type to prohibit the creation of Svgs without using +// the `svg` template literal tag. +export type { BitSvg }; + +export function isBitSvg(svgContent: unknown): svgContent is BitSvg { + return svgContent instanceof BitSvg; +} + +export class DynamicContentNotAllowedError extends Error { + constructor() { + super("Dynamic content in icons is not allowed due to risk of user-injected XSS."); + } +} + +export function svg(strings: TemplateStringsArray, ...values: unknown[]): BitSvg { + if (values.length > 0) { + throw new DynamicContentNotAllowedError(); + } + + return new BitSvg(strings[0]); +} diff --git a/libs/assets/src/svg/svgs/account-warning.icon.ts b/libs/assets/src/svg/svgs/account-warning.icon.ts index 80e29dad870..81bf62d6e64 100644 --- a/libs/assets/src/svg/svgs/account-warning.icon.ts +++ b/libs/assets/src/svg/svgs/account-warning.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AccountWarning = svgIcon` +export const AccountWarning = svg` diff --git a/libs/assets/src/svg/svgs/active-send.icon.ts b/libs/assets/src/svg/svgs/active-send.icon.ts index 3b12ee865d1..3016466e062 100644 --- a/libs/assets/src/svg/svgs/active-send.icon.ts +++ b/libs/assets/src/svg/svgs/active-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ActiveSendIcon = svgIcon` +export const ActiveSendIcon = svg` diff --git a/libs/assets/src/svg/svgs/admin-console.ts b/libs/assets/src/svg/svgs/admin-console.ts index 3e8f47ec4a5..146c834b442 100644 --- a/libs/assets/src/svg/svgs/admin-console.ts +++ b/libs/assets/src/svg/svgs/admin-console.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const AdminConsoleLogo = svgIcon` +const AdminConsoleLogo = svg` diff --git a/libs/assets/src/svg/svgs/auto-confirmation.ts b/libs/assets/src/svg/svgs/auto-confirmation.ts index 2a1416a5d25..5d0e0dd380c 100644 --- a/libs/assets/src/svg/svgs/auto-confirmation.ts +++ b/libs/assets/src/svg/svgs/auto-confirmation.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const AutoConfirmSvg = svgIcon` +export const AutoConfirmSvg = svg` `; diff --git a/libs/assets/src/svg/svgs/background-left-illustration.ts b/libs/assets/src/svg/svgs/background-left-illustration.ts index a34f31f1621..f091f905c64 100644 --- a/libs/assets/src/svg/svgs/background-left-illustration.ts +++ b/libs/assets/src/svg/svgs/background-left-illustration.ts @@ -1,5 +1,5 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundLeftIllustration = svgIcon` +export const BackgroundLeftIllustration = svg` `; diff --git a/libs/assets/src/svg/svgs/background-right-illustration.ts b/libs/assets/src/svg/svgs/background-right-illustration.ts index 1c488f7242d..8f3bbba3462 100644 --- a/libs/assets/src/svg/svgs/background-right-illustration.ts +++ b/libs/assets/src/svg/svgs/background-right-illustration.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BackgroundRightIllustration = svgIcon` +export const BackgroundRightIllustration = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-icon.ts b/libs/assets/src/svg/svgs/bitwarden-icon.ts index 203460952b5..43aea78ced6 100644 --- a/libs/assets/src/svg/svgs/bitwarden-icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenIcon = svgIcon` +export const BitwardenIcon = svg` diff --git a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts index 9c1c7248ec6..85d0a471a6e 100644 --- a/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts +++ b/libs/assets/src/svg/svgs/bitwarden-logo.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BitwardenLogo = svgIcon` +export const BitwardenLogo = svg` Bitwarden diff --git a/libs/assets/src/svg/svgs/browser-extension.ts b/libs/assets/src/svg/svgs/browser-extension.ts index c15a536c007..2c40c584255 100644 --- a/libs/assets/src/svg/svgs/browser-extension.ts +++ b/libs/assets/src/svg/svgs/browser-extension.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BrowserExtensionIcon = svgIcon` +export const BrowserExtensionIcon = svg` diff --git a/libs/assets/src/svg/svgs/business-unit-portal.ts b/libs/assets/src/svg/svgs/business-unit-portal.ts index db3a6b8ef4f..cd06afcbf9a 100644 --- a/libs/assets/src/svg/svgs/business-unit-portal.ts +++ b/libs/assets/src/svg/svgs/business-unit-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BusinessUnitPortalLogo = svgIcon` +const BusinessUnitPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/business-welcome.icon.ts b/libs/assets/src/svg/svgs/business-welcome.icon.ts index 06c4950ec18..1d1caed8d47 100644 --- a/libs/assets/src/svg/svgs/business-welcome.icon.ts +++ b/libs/assets/src/svg/svgs/business-welcome.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const BusinessWelcome = svgIcon` +export const BusinessWelcome = svg` diff --git a/libs/assets/src/svg/svgs/carousel-icon.ts b/libs/assets/src/svg/svgs/carousel-icon.ts index e29fd952098..4d645ad8029 100644 --- a/libs/assets/src/svg/svgs/carousel-icon.ts +++ b/libs/assets/src/svg/svgs/carousel-icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CarouselIcon = svgIcon` +export const CarouselIcon = svg` diff --git a/libs/assets/src/svg/svgs/credit-card.icon.ts b/libs/assets/src/svg/svgs/credit-card.icon.ts index e334766fac7..dd0eb6a121a 100644 --- a/libs/assets/src/svg/svgs/credit-card.icon.ts +++ b/libs/assets/src/svg/svgs/credit-card.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const CreditCardIcon = svgIcon` +export const CreditCardIcon = svg` diff --git a/libs/assets/src/svg/svgs/deactivated-org.ts b/libs/assets/src/svg/svgs/deactivated-org.ts index 75b25e3fd27..d2566712a98 100644 --- a/libs/assets/src/svg/svgs/deactivated-org.ts +++ b/libs/assets/src/svg/svgs/deactivated-org.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DeactivatedOrg = svgIcon` +export const DeactivatedOrg = svg` diff --git a/libs/assets/src/svg/svgs/devices.icon.ts b/libs/assets/src/svg/svgs/devices.icon.ts index 7c97df48657..a3a4aa06442 100644 --- a/libs/assets/src/svg/svgs/devices.icon.ts +++ b/libs/assets/src/svg/svgs/devices.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DevicesIcon = svgIcon` +export const DevicesIcon = svg` diff --git a/libs/assets/src/svg/svgs/domain.icon.ts b/libs/assets/src/svg/svgs/domain.icon.ts index 04bd173be98..af47b1930d7 100644 --- a/libs/assets/src/svg/svgs/domain.icon.ts +++ b/libs/assets/src/svg/svgs/domain.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const DomainIcon = svgIcon` +export const DomainIcon = svg` diff --git a/libs/assets/src/svg/svgs/empty-trash.ts b/libs/assets/src/svg/svgs/empty-trash.ts index d6c0043d880..da48bd69c3e 100644 --- a/libs/assets/src/svg/svgs/empty-trash.ts +++ b/libs/assets/src/svg/svgs/empty-trash.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const EmptyTrash = svgIcon` +export const EmptyTrash = svg` diff --git a/libs/assets/src/svg/svgs/favorites.icon.ts b/libs/assets/src/svg/svgs/favorites.icon.ts index 4725d0b0a7c..8777eaeef88 100644 --- a/libs/assets/src/svg/svgs/favorites.icon.ts +++ b/libs/assets/src/svg/svgs/favorites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const FavoritesIcon = svgIcon` +export const FavoritesIcon = svg` diff --git a/libs/assets/src/svg/svgs/gear.ts b/libs/assets/src/svg/svgs/gear.ts index 261c6d262e1..c04dc8e1a17 100644 --- a/libs/assets/src/svg/svgs/gear.ts +++ b/libs/assets/src/svg/svgs/gear.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GearIcon = svgIcon` +export const GearIcon = svg` diff --git a/libs/assets/src/svg/svgs/generator.ts b/libs/assets/src/svg/svgs/generator.ts index 52368ddc204..26b09f19455 100644 --- a/libs/assets/src/svg/svgs/generator.ts +++ b/libs/assets/src/svg/svgs/generator.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const GeneratorInactive = svgIcon` +export const GeneratorInactive = svg` `; -export const GeneratorActive = svgIcon` +export const GeneratorActive = svg` diff --git a/libs/assets/src/svg/svgs/item-types.ts b/libs/assets/src/svg/svgs/item-types.ts index 50ed51bd018..b066df72b0d 100644 --- a/libs/assets/src/svg/svgs/item-types.ts +++ b/libs/assets/src/svg/svgs/item-types.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ItemTypes = svgIcon` +export const ItemTypes = svg` diff --git a/libs/assets/src/svg/svgs/lock.icon.ts b/libs/assets/src/svg/svgs/lock.icon.ts index 9d73ad6294c..f42630739f1 100644 --- a/libs/assets/src/svg/svgs/lock.icon.ts +++ b/libs/assets/src/svg/svgs/lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LockIcon = svgIcon` +export const LockIcon = svg` diff --git a/libs/assets/src/svg/svgs/login-cards.ts b/libs/assets/src/svg/svgs/login-cards.ts index 3a43b1a0121..13c456a1658 100644 --- a/libs/assets/src/svg/svgs/login-cards.ts +++ b/libs/assets/src/svg/svgs/login-cards.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const LoginCards = svgIcon` +export const LoginCards = svg` diff --git a/libs/assets/src/svg/svgs/no-credentials.icon.ts b/libs/assets/src/svg/svgs/no-credentials.icon.ts index bfecfd4834c..da7795db808 100644 --- a/libs/assets/src/svg/svgs/no-credentials.icon.ts +++ b/libs/assets/src/svg/svgs/no-credentials.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoCredentialsIcon = svgIcon` +export const NoCredentialsIcon = svg` diff --git a/libs/assets/src/svg/svgs/no-folders.ts b/libs/assets/src/svg/svgs/no-folders.ts index c8858ca83e5..7facc01e4d6 100644 --- a/libs/assets/src/svg/svgs/no-folders.ts +++ b/libs/assets/src/svg/svgs/no-folders.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoFolders = svgIcon` +export const NoFolders = svg` diff --git a/libs/assets/src/svg/svgs/no-results.ts b/libs/assets/src/svg/svgs/no-results.ts index 5f914ad213c..75ad485181f 100644 --- a/libs/assets/src/svg/svgs/no-results.ts +++ b/libs/assets/src/svg/svgs/no-results.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoResults = svgIcon` +export const NoResults = svg` diff --git a/libs/assets/src/svg/svgs/no-send.icon.ts b/libs/assets/src/svg/svgs/no-send.icon.ts index a246c0177f8..a7125caabf6 100644 --- a/libs/assets/src/svg/svgs/no-send.icon.ts +++ b/libs/assets/src/svg/svgs/no-send.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const NoSendsIcon = svgIcon` +export const NoSendsIcon = svg` diff --git a/libs/assets/src/svg/svgs/party.ts b/libs/assets/src/svg/svgs/party.ts index efa5331f4fc..991f4a3deda 100644 --- a/libs/assets/src/svg/svgs/party.ts +++ b/libs/assets/src/svg/svgs/party.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Party = svgIcon` +export const Party = svg` diff --git a/libs/assets/src/svg/svgs/password-manager.ts b/libs/assets/src/svg/svgs/password-manager.ts index 5b19562e022..aa7e8ecc52d 100644 --- a/libs/assets/src/svg/svgs/password-manager.ts +++ b/libs/assets/src/svg/svgs/password-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const PasswordManagerLogo = svgIcon` +const PasswordManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/provider-portal.ts b/libs/assets/src/svg/svgs/provider-portal.ts index fad2ce6b864..97d23633a9e 100644 --- a/libs/assets/src/svg/svgs/provider-portal.ts +++ b/libs/assets/src/svg/svgs/provider-portal.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const ProviderPortalLogo = svgIcon` +const ProviderPortalLogo = svg` diff --git a/libs/assets/src/svg/svgs/registration-check-email.icon.ts b/libs/assets/src/svg/svgs/registration-check-email.icon.ts index ae4cf3098e6..006a60bc7c0 100644 --- a/libs/assets/src/svg/svgs/registration-check-email.icon.ts +++ b/libs/assets/src/svg/svgs/registration-check-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationCheckEmailIcon = svgIcon` +export const RegistrationCheckEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/registration-user-add.icon.ts b/libs/assets/src/svg/svgs/registration-user-add.icon.ts index 7428daa5848..358412c38eb 100644 --- a/libs/assets/src/svg/svgs/registration-user-add.icon.ts +++ b/libs/assets/src/svg/svgs/registration-user-add.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RegistrationUserAddIcon = svgIcon` +export const RegistrationUserAddIcon = svg` diff --git a/libs/assets/src/svg/svgs/report-breach.icon.ts b/libs/assets/src/svg/svgs/report-breach.icon.ts index 83dd6c72b82..e926388e333 100644 --- a/libs/assets/src/svg/svgs/report-breach.icon.ts +++ b/libs/assets/src/svg/svgs/report-breach.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportBreach = svgIcon` +export const ReportBreach = svg` diff --git a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts index 0309eb643d9..590e7d7d1a1 100644 --- a/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts +++ b/libs/assets/src/svg/svgs/report-exposed-passwords.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportExposedPasswords = svgIcon` +export const ReportExposedPasswords = svg` diff --git a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts index 487381ccaa9..831a6570812 100644 --- a/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts +++ b/libs/assets/src/svg/svgs/report-unsecured-websites.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const ReportUnsecuredWebsites = svgIcon` +export const ReportUnsecuredWebsites = svg` diff --git a/libs/assets/src/svg/svgs/restricted-view.ts b/libs/assets/src/svg/svgs/restricted-view.ts index 5eec1a4a972..7bf40467ac6 100644 --- a/libs/assets/src/svg/svgs/restricted-view.ts +++ b/libs/assets/src/svg/svgs/restricted-view.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const RestrictedView = svgIcon` +export const RestrictedView = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager-alt.ts b/libs/assets/src/svg/svgs/secrets-manager-alt.ts index 98640803ca9..70fa7d6386c 100644 --- a/libs/assets/src/svg/svgs/secrets-manager-alt.ts +++ b/libs/assets/src/svg/svgs/secrets-manager-alt.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SecretsManagerAlt = svgIcon` +export const SecretsManagerAlt = svg` diff --git a/libs/assets/src/svg/svgs/secrets-manager.ts b/libs/assets/src/svg/svgs/secrets-manager.ts index 62b54174c55..3cd66df59e3 100644 --- a/libs/assets/src/svg/svgs/secrets-manager.ts +++ b/libs/assets/src/svg/svgs/secrets-manager.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const SecretsManagerLogo = svgIcon` +const SecretsManagerLogo = svg` diff --git a/libs/assets/src/svg/svgs/security.ts b/libs/assets/src/svg/svgs/security.ts index 6e475b25ab7..119d0164599 100644 --- a/libs/assets/src/svg/svgs/security.ts +++ b/libs/assets/src/svg/svgs/security.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const Security = svgIcon` +export const Security = svg` diff --git a/libs/assets/src/svg/svgs/send.ts b/libs/assets/src/svg/svgs/send.ts index f09f59a5388..309844f9fd9 100644 --- a/libs/assets/src/svg/svgs/send.ts +++ b/libs/assets/src/svg/svgs/send.ts @@ -1,12 +1,12 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SendInactive = svgIcon` +export const SendInactive = svg` `; -export const SendActive = svgIcon` +export const SendActive = svg` diff --git a/libs/assets/src/svg/svgs/settings.ts b/libs/assets/src/svg/svgs/settings.ts index 3b54bbbd88c..b0e42821c6b 100644 --- a/libs/assets/src/svg/svgs/settings.ts +++ b/libs/assets/src/svg/svgs/settings.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SettingsInactive = svgIcon` +export const SettingsInactive = svg` `; -export const SettingsActive = svgIcon` +export const SettingsActive = svg` diff --git a/libs/assets/src/svg/svgs/shield.ts b/libs/assets/src/svg/svgs/shield.ts index af626a98e9d..bd5f9e02d1d 100644 --- a/libs/assets/src/svg/svgs/shield.ts +++ b/libs/assets/src/svg/svgs/shield.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -const BitwardenShield = svgIcon` +const BitwardenShield = svg` diff --git a/libs/assets/src/svg/svgs/sso-key.icon.ts b/libs/assets/src/svg/svgs/sso-key.icon.ts index ad81c707449..d6e45b13b42 100644 --- a/libs/assets/src/svg/svgs/sso-key.icon.ts +++ b/libs/assets/src/svg/svgs/sso-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const SsoKeyIcon = svgIcon` +export const SsoKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts index 622875b59f2..11d2fafb745 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-authenticator.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthAuthenticatorIcon = svgIcon` +export const TwoFactorAuthAuthenticatorIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts index 5bf43334d18..a40a6418885 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-duo.icon.ts @@ -1,8 +1,10 @@ -// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; -export const TwoFactorAuthDuoIcon = svgIcon` +// this svg includes the Duo logo, which contains colors not part of our bitwarden theme colors + +import { svg } from "../svg"; + +export const TwoFactorAuthDuoIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts index 20709a8a1e1..8fdee85da82 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-email.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthEmailIcon = svgIcon` +export const TwoFactorAuthEmailIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts index 0e467bf1901..3eab3bb00c6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key-failed.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyFailedIcon = svgIcon` +export const TwoFactorAuthSecurityKeyFailedIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts index f10068b735b..830db83f3e8 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-security-key.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthSecurityKeyIcon = svgIcon` +export const TwoFactorAuthSecurityKeyIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts index b9114259584..9f0decb1f36 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-webauthn.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const TwoFactorAuthWebAuthnIcon = svgIcon` +export const TwoFactorAuthWebAuthnIcon = svg` diff --git a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts index d4d38c363ae..6368442cde6 100644 --- a/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts +++ b/libs/assets/src/svg/svgs/two-factor-auth-yubico.icon.ts @@ -1,8 +1,9 @@ -// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors /* eslint-disable @bitwarden/components/require-theme-colors-in-svg */ -import { svgIcon } from "../icon-service"; +// this svg includes the Yubico logo, which contains colors not part of our bitwarden theme colors -export const TwoFactorAuthYubicoIcon = svgIcon` +import { svg } from "../svg"; + +export const TwoFactorAuthYubicoIcon = svg` diff --git a/libs/assets/src/svg/svgs/unlocked.icon.ts b/libs/assets/src/svg/svgs/unlocked.icon.ts index 6ce40819e44..1a754733d26 100644 --- a/libs/assets/src/svg/svgs/unlocked.icon.ts +++ b/libs/assets/src/svg/svgs/unlocked.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UnlockedIcon = svgIcon` +export const UnlockedIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-lock.icon.ts b/libs/assets/src/svg/svgs/user-lock.icon.ts index cc848a05769..5deead382b3 100644 --- a/libs/assets/src/svg/svgs/user-lock.icon.ts +++ b/libs/assets/src/svg/svgs/user-lock.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserLockIcon = svgIcon` +export const UserLockIcon = svg` diff --git a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts index 19e1aa3e6cd..c175bb78993 100644 --- a/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts +++ b/libs/assets/src/svg/svgs/user-verification-biometrics-fingerprint.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const UserVerificationBiometricsIcon = svgIcon` +export const UserVerificationBiometricsIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault-open.ts b/libs/assets/src/svg/svgs/vault-open.ts index 3ad82b9bbac..52e8a971d60 100644 --- a/libs/assets/src/svg/svgs/vault-open.ts +++ b/libs/assets/src/svg/svgs/vault-open.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultOpen = svgIcon` +export const VaultOpen = svg` diff --git a/libs/assets/src/svg/svgs/vault.icon.ts b/libs/assets/src/svg/svgs/vault.icon.ts index 61ec2589b34..1f442ad0471 100644 --- a/libs/assets/src/svg/svgs/vault.icon.ts +++ b/libs/assets/src/svg/svgs/vault.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultIcon = svgIcon` +export const VaultIcon = svg` diff --git a/libs/assets/src/svg/svgs/vault.ts b/libs/assets/src/svg/svgs/vault.ts index 1c699f2ba8e..8e1acab2670 100644 --- a/libs/assets/src/svg/svgs/vault.ts +++ b/libs/assets/src/svg/svgs/vault.ts @@ -1,13 +1,13 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const VaultInactive = svgIcon` +export const VaultInactive = svg` `; -export const VaultActive = svgIcon` +export const VaultActive = svg` diff --git a/libs/assets/src/svg/svgs/wave.icon.ts b/libs/assets/src/svg/svgs/wave.icon.ts index 6c97d0fbbb3..7b00ba0f3eb 100644 --- a/libs/assets/src/svg/svgs/wave.icon.ts +++ b/libs/assets/src/svg/svgs/wave.icon.ts @@ -1,6 +1,6 @@ -import { svgIcon } from "../icon-service"; +import { svg } from "../svg"; -export const WaveIcon = svgIcon` +export const WaveIcon = svg` diff --git a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts index e7a3e99759c..87b5173a6a7 100644 --- a/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts +++ b/libs/auth/src/angular/registration/registration-link-expired/registration-link-expired.component.ts @@ -9,7 +9,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { TwoFactorTimeoutIcon } from "@bitwarden/assets/svg"; // 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 { ButtonModule, IconModule } from "@bitwarden/components"; +import { ButtonModule, SvgModule } from "@bitwarden/components"; /** * RegistrationLinkExpiredComponentData @@ -24,7 +24,7 @@ export interface RegistrationLinkExpiredComponentData { @Component({ selector: "auth-registration-link-expired", templateUrl: "./registration-link-expired.component.html", - imports: [CommonModule, JslibModule, RouterModule, IconModule, ButtonModule], + imports: [CommonModule, JslibModule, RouterModule, SvgModule, ButtonModule], }) export class RegistrationLinkExpiredComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts index 714f6d49342..1161af836b4 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.ts @@ -20,7 +20,7 @@ import { ButtonModule, CheckboxModule, FormFieldModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -54,7 +54,7 @@ const DEFAULT_MARKETING_EMAILS_PREF_BY_REGION: Record = { CheckboxModule, ButtonModule, LinkModule, - IconModule, + SvgModule, RegistrationEnvSelectorComponent, ], }) diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html index 277ba047add..bf9482c7987 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.html @@ -11,30 +11,30 @@ [ngSwitch]="provider.type" class="tw-w-16 md:tw-w-20 tw-mr-2 sm:tw-mr-4" > - - + - + - + - + - + + [content]="Icons.TwoFactorAuthWebAuthnIcon" + >
    {{ provider.name }} {{ provider.description }} diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts index d8b2ab2508b..53ae509f182 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-options.component.ts @@ -18,7 +18,7 @@ import { ButtonModule, DialogModule, DialogService, - IconModule, + SvgModule, ItemModule, TypographyModule, } from "@bitwarden/components"; @@ -39,7 +39,7 @@ export type TwoFactorOptionsDialogResult = { ButtonModule, TypographyModule, ItemModule, - IconModule, + SvgModule, ], providers: [], }) diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html index 5699f3dd9a4..8e8f41c394d 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.html +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.html @@ -42,7 +42,7 @@ >
    - +

    {{ "verifyWithBiometrics" | i18n }}

    diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index 296359c92ff..af73cc3de99 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -28,7 +28,7 @@ import { CalloutModule, FormFieldModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, } from "@bitwarden/components"; @@ -64,7 +64,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt FormFieldModule, AsyncActionsModule, IconButtonModule, - IconModule, + SvgModule, LinkModule, ButtonModule, CalloutModule, 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 84140a8953a..b8f8851864b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -3,7 +3,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router"; import { Subject, filter, of, switchMap, tap } from "rxjs"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Translation } from "../dialog"; @@ -27,7 +27,7 @@ export interface AnonLayoutWrapperData { /** * The icon to display on the page. Pass null to hide the icon. */ - pageIcon: Icon | null; + pageIcon: BitSvg | null; /** * Optional flag to either show the optional environment selector (false) or just a readonly hostname (true). */ @@ -57,7 +57,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected pageTitle?: string | null; protected pageSubtitle?: string | null; - protected pageIcon: Icon | null = null; + protected pageIcon: BitSvg | null = null; protected showReadonlyHostname?: boolean | null; protected maxWidth?: LandingContentMaxWidthType | null; protected hideCardWrapper?: boolean | null; diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index eded556cd53..953a5e769cf 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -11,15 +11,15 @@ import { import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; -import { BitwardenLogo, Icon } from "@bitwarden/assets/svg"; +import { BitwardenLogo, BitSvg } 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 { IconModule } from "../icon"; import { LandingContentMaxWidthType } from "../landing-layout"; import { LandingLayoutModule } from "../landing-layout/landing-layout.module"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -28,7 +28,7 @@ import { TypographyModule } from "../typography"; selector: "auth-anon-layout", templateUrl: "./anon-layout.component.html", imports: [ - IconModule, + SvgModule, CommonModule, TypographyModule, SharedModule, @@ -45,7 +45,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly title = input(); readonly subtitle = input(); - readonly icon = model.required(); + readonly icon = model.required(); readonly showReadonlyHostname = input(false); readonly hideLogo = input(false); readonly hideFooter = input(false); diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 01cdc04ad73..ed6df181c85 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -2,7 +2,7 @@ import { ActivatedRoute, RouterModule } from "@angular/router"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { BehaviorSubject, of } from "rxjs"; -import { Icon, LockIcon } from "@bitwarden/assets/svg"; +import { BitSvg, LockIcon } from "@bitwarden/assets/svg"; import { ClientType } from "@bitwarden/common/enums"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -23,7 +23,7 @@ type StoryArgs = AnonLayoutComponent & { contentLength: "normal" | "long" | "thin"; showSecondary: boolean; useDefaultIcon: boolean; - icon: Icon; + icon: BitSvg; includeHeaderActions: boolean; }; diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index c2185203034..ff1a8c16d5f 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -1,7 +1,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LinkModule, IconModule } from "@bitwarden/components"; +import { LinkModule, SvgModule } from "@bitwarden/components"; import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -13,7 +13,7 @@ export default { component: CalloutComponent, decorators: [ moduleMetadata({ - imports: [LinkModule, IconModule], + imports: [LinkModule, SvgModule], providers: [ { provide: I18nService, diff --git a/libs/components/src/header/header.stories.ts b/libs/components/src/header/header.stories.ts index 620f39a5dc3..23c2bb2edb5 100644 --- a/libs/components/src/header/header.stories.ts +++ b/libs/components/src/header/header.stories.ts @@ -14,7 +14,7 @@ import { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, @@ -40,7 +40,7 @@ export default { BreadcrumbsModule, ButtonModule, IconButtonModule, - IconModule, + SvgModule, InputModule, MenuModule, NavigationModule, diff --git a/libs/components/src/icon/icon.component.ts b/libs/components/src/icon/icon.component.ts index f57a3627383..c2dc468dc71 100644 --- a/libs/components/src/icon/icon.component.ts +++ b/libs/components/src/icon/icon.component.ts @@ -1,35 +1,30 @@ -import { Component, effect, input } from "@angular/core"; -import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; -import { Icon, isIcon } from "@bitwarden/assets/svg"; +import { BitwardenIcon } from "../shared/icon"; -// 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-icon", + standalone: true, host: { - "[attr.aria-hidden]": "!ariaLabel()", + "[class]": "classList()", + "[attr.aria-hidden]": "ariaLabel() ? null : true", "[attr.aria-label]": "ariaLabel()", - "[innerHtml]": "innerHtml", - class: "tw-max-h-full tw-flex tw-justify-center", }, template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BitIconComponent { - innerHtml: SafeHtml | null = null; - - readonly icon = input(); +export class IconComponent { + /** + * The Bitwarden icon name (e.g., "bwi-lock", "bwi-user") + */ + readonly name = input.required(); + /** + * Accessible label for the icon + */ readonly ariaLabel = input(); - constructor(private domSanitizer: DomSanitizer) { - effect(() => { - const icon = this.icon(); - if (!isIcon(icon)) { - return; - } - const svg = icon.svg; - this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg); - }); - } + protected readonly classList = computed(() => { + return ["bwi", this.name()].join(" "); + }); } diff --git a/libs/components/src/icon/icon.mdx b/libs/components/src/icon/icon.mdx index 4f6f13c895e..0914d681e59 100644 --- a/libs/components/src/icon/icon.mdx +++ b/libs/components/src/icon/icon.mdx @@ -8,113 +8,40 @@ import * as stories from "./icon.stories"; import { IconModule } from "@bitwarden/components"; ``` -# Icon Use Instructions +# Icon -- Icons will generally be attached to the associated Jira task. - - Designers should minify any SVGs before attaching them to Jira using a tool like - [SVGOMG](https://jakearchibald.github.io/svgomg/). - - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon - is desired. +The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font classes. -## Developer Instructions +## Basic Usage -1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. - - The SVG should be formatted using either a built-in formatter or an external tool like - [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying - classes easier. +```html + +``` -2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` - lib. +## Icon Names -3. **Import** `svgIcon` from `./icon-service`. +All available icon names are defined in the `BitwardenIcon` type. Icons use the `bwi-*` naming +convention (e.g., `bwi-lock`, `bwi-user`, `bwi-key`). -4. **Define and export** a `const` to represent your `svgIcon`. +## Accessibility - ```typescript - export const ExampleIcon = svgIcon``; - ``` +By default, icons are decorative and marked with `aria-hidden="true"`. To make an icon accessible, +provide an `ariaLabel`: -5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. - - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when - styling the inside of an SVG path. +```html + +``` - - A non-comprehensive list of common colors and their associated classes is below: +## Styling - | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | - | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | - | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | - | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | - | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | - | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | - | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | - | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | +The component renders as an inline element. Apply standard CSS classes or styles to customize +appearance: - - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out - the appropriate Tailwind class: - - **Option 1: Figma** - - Open the SVG in Figma. - - Click on an individual path on the SVG until you see the path's properties in the - right-hand panel. - - Scroll down to the Colors section. - - Example: `Color/Illustration/Outline` - - This also includes Hex or RGB values that can be used to find the appropriate Tailwind - variable as well if you follow the manual search option below. - - Create the appropriate stroke or fill class from the color used. - - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which - corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. - - **Option 2: Manual Search** - - Take the path's stroke or fill hex value and convert it to RGB using a tool like - [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). - - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable - that corresponds to the color. - - Create the appropriate stroke or fill class using the Tailwind variable. - - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` - or `tw-fill-illustration-outline`. +```html + +``` -6. **Remove any hardcoded width or height attributes** if your SVG has a configured - [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order - to allow the SVG to scale to fit its container. - - **Note:** Scaling is required for any SVG used as an - [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. +## Note on SVG Icons -7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the - referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). - -8. **Import your SVG const** anywhere you want to use the SVG. - - **Angular Component Example:** - - **TypeScript:** - - ```typescript - import { Component } from "@angular/core"; - import { IconModule } from '@bitwarden/components'; - import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; - - @Component({ - selector: "app-example", - standalone: true, - imports: [IconModule], - templateUrl: "./example.component.html", - }) - export class ExampleComponent { - readonly Icons = { ExampleIcon, Example2Icon }; - ... - } - ``` - - - **HTML:** - - > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an - > `ariaLabel` is explicitly provided to the `` component - - ```html - - ``` - - With `ariaLabel` - - ```html - - ``` - -9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client - which supports multiple style modes. +For SVG illustrations (not font icons), use the `bit-svg` component instead. See the Svg component +documentation for details. diff --git a/libs/components/src/icon/icon.module.ts b/libs/components/src/icon/icon.module.ts index 3d15b5bb3c3..b3e65619bd3 100644 --- a/libs/components/src/icon/icon.module.ts +++ b/libs/components/src/icon/icon.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; @NgModule({ - imports: [BitIconComponent], - exports: [BitIconComponent], + imports: [IconComponent], + exports: [IconComponent], }) export class IconModule {} diff --git a/libs/components/src/icon/icon.stories.ts b/libs/components/src/icon/icon.stories.ts index e94a7aaf51c..5626407ea51 100644 --- a/libs/components/src/icon/icon.stories.ts +++ b/libs/components/src/icon/icon.stories.ts @@ -1,50 +1,61 @@ -import { Meta } from "@storybook/angular"; +import { Meta, StoryObj } from "@storybook/angular"; -import * as SvgIcons from "@bitwarden/assets/svg"; +import { BITWARDEN_ICONS } from "../shared/icon"; -import { BitIconComponent } from "./icon.component"; +import { IconComponent } from "./icon.component"; export default { title: "Component Library/Icon", - component: BitIconComponent, + component: IconComponent, parameters: { design: { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", }, }, -} as Meta; + argTypes: { + name: { + control: { type: "select" }, + options: BITWARDEN_ICONS, + }, + }, +} as Meta; -const { - // Filtering out the few non-icons in the libs/assets/svg import - // eslint-disable-next-line @typescript-eslint/no-unused-vars - DynamicContentNotAllowedError: _DynamicContentNotAllowedError, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isIcon, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - svgIcon, - ...Icons -}: { - [key: string]: any; -} = SvgIcons; +type Story = StoryObj; -export const Default = { - render: (args: { icons: [string, any][] }) => ({ - props: args, - template: /*html*/ ` -
    - @for (icon of icons; track icon[0]) { -
    -
    {{icon[0]}}
    -
    - -
    -
    - } -
    - `, - }), +export const Default: Story = { args: { - icons: Object.entries(Icons), + name: "bwi-lock", }, }; + +export const AllIcons: Story = { + render: () => ({ + template: ` +
    + @for (icon of icons; track icon) { +
    + + {{ icon }} +
    + } +
    + `, + props: { + icons: BITWARDEN_ICONS, + }, + }), +}; + +export const WithAriaLabel: Story = { + args: { + name: "bwi-lock", + ariaLabel: "Secure lock icon", + }, +}; + +export const CompareWithLegacy: Story = { + render: () => ({ + template: ` `, + }), +}; diff --git a/libs/components/src/icon/index.ts b/libs/components/src/icon/index.ts index 1ee66e59837..670966a7630 100644 --- a/libs/components/src/icon/index.ts +++ b/libs/components/src/icon/index.ts @@ -1 +1,2 @@ export * from "./icon.module"; +export * from "./icon.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 9c4dadadd4b..80fd6fc05a6 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -22,6 +22,7 @@ export * from "./form-field"; export * from "./header"; export * from "./icon-button"; export * from "./icon"; +export * from "./svg"; export * from "./icon-tile"; export * from "./input"; export * from "./item"; diff --git a/libs/components/src/landing-layout/landing-header.component.html b/libs/components/src/landing-layout/landing-header.component.html index ed6d34ef23b..882f1b96c99 100644 --- a/libs/components/src/landing-layout/landing-header.component.html +++ b/libs/components/src/landing-layout/landing-header.component.html @@ -4,7 +4,7 @@ [routerLink]="['/']" class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top" > - + }
    diff --git a/libs/components/src/landing-layout/landing-header.component.ts b/libs/components/src/landing-layout/landing-header.component.ts index eb5329e915d..c0fb3cd67f1 100644 --- a/libs/components/src/landing-layout/landing-header.component.ts +++ b/libs/components/src/landing-layout/landing-header.component.ts @@ -3,8 +3,8 @@ import { RouterModule } from "@angular/router"; import { BitwardenLogo } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; import { SharedModule } from "../shared"; +import { SvgModule } from "../svg"; /** * Header component for landing pages with optional Bitwarden logo and header actions slot. @@ -34,7 +34,7 @@ import { SharedModule } from "../shared"; selector: "bit-landing-header", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-header.component.html", - imports: [RouterModule, IconModule, SharedModule], + imports: [RouterModule, SvgModule, SharedModule], }) export class LandingHeaderComponent { readonly hideLogo = input(false); diff --git a/libs/components/src/landing-layout/landing-hero.component.html b/libs/components/src/landing-layout/landing-hero.component.html index dbce6a7c585..9394bb03c63 100644 --- a/libs/components/src/landing-layout/landing-hero.component.html +++ b/libs/components/src/landing-layout/landing-hero.component.html @@ -6,7 +6,7 @@
    - +
    } diff --git a/libs/components/src/landing-layout/landing-hero.component.ts b/libs/components/src/landing-layout/landing-hero.component.ts index b29e9768efd..d3b9ffd0ee9 100644 --- a/libs/components/src/landing-layout/landing-hero.component.ts +++ b/libs/components/src/landing-layout/landing-hero.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, input } from "@angular/core"; -import { Icon } from "@bitwarden/assets/svg"; +import { BitSvg } from "@bitwarden/assets/svg"; -import { IconModule } from "../icon"; +import { SvgModule } from "../svg"; import { TypographyModule } from "../typography"; /** @@ -31,10 +31,10 @@ import { TypographyModule } from "../typography"; selector: "bit-landing-hero", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-hero.component.html", - imports: [IconModule, TypographyModule], + imports: [SvgModule, TypographyModule], }) export class LandingHeroComponent { - readonly icon = input(null); + 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 index 1164f538116..a33054e8e64 100644 --- a/libs/components/src/landing-layout/landing-layout.component.html +++ b/libs/components/src/landing-layout/landing-layout.component.html @@ -13,12 +13,12 @@
    - +
    - +
    } diff --git a/libs/components/src/landing-layout/landing-layout.component.ts b/libs/components/src/landing-layout/landing-layout.component.ts index 520cca945d6..65c7302e828 100644 --- a/libs/components/src/landing-layout/landing-layout.component.ts +++ b/libs/components/src/landing-layout/landing-layout.component.ts @@ -3,7 +3,7 @@ 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"; +import { SvgModule } from "../svg"; /** * Root layout component for landing pages providing a full-screen container with optional decorative background illustrations. @@ -27,7 +27,7 @@ import { IconModule } from "../icon"; selector: "bit-landing-layout", changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: "./landing-layout.component.html", - imports: [IconModule], + imports: [SvgModule], }) export class LandingLayoutComponent { readonly hideBackgroundIllustration = input(false); diff --git a/libs/components/src/navigation/nav-logo.component.html b/libs/components/src/navigation/nav-logo.component.html index 9f18855ae13..8323a0f3479 100644 --- a/libs/components/src/navigation/nav-logo.component.html +++ b/libs/components/src/navigation/nav-logo.component.html @@ -16,6 +16,6 @@ routerLinkActive ariaCurrentWhenActive="page" > - +
    diff --git a/libs/components/src/navigation/nav-logo.component.ts b/libs/components/src/navigation/nav-logo.component.ts index fec50ee8902..4b3dc471edb 100644 --- a/libs/components/src/navigation/nav-logo.component.ts +++ b/libs/components/src/navigation/nav-logo.component.ts @@ -1,16 +1,16 @@ import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core"; import { RouterLinkActive, RouterLink } from "@angular/router"; -import { BitwardenShield, Icon } from "@bitwarden/assets/svg"; +import { BitwardenShield, BitSvg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; import { SideNavService } from "./side-nav.service"; @Component({ selector: "bit-nav-logo", templateUrl: "./nav-logo.component.html", - imports: [RouterLinkActive, RouterLink, BitIconComponent], + imports: [RouterLinkActive, RouterLink, SvgComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class NavLogoComponent { @@ -26,7 +26,7 @@ export class NavLogoComponent { /** * Icon that is displayed when the side nav is open */ - readonly openIcon = input.required(); + readonly openIcon = input.required(); /** * Route to be passed to internal `routerLink` diff --git a/libs/components/src/no-items/no-items.component.html b/libs/components/src/no-items/no-items.component.html index e728584a41a..46a5c25526a 100644 --- a/libs/components/src/no-items/no-items.component.html +++ b/libs/components/src/no-items/no-items.component.html @@ -1,7 +1,7 @@
    - +

    diff --git a/libs/components/src/no-items/no-items.component.ts b/libs/components/src/no-items/no-items.component.ts index c6e52a1f83d..d2cacfd2251 100644 --- a/libs/components/src/no-items/no-items.component.ts +++ b/libs/components/src/no-items/no-items.component.ts @@ -1,18 +1,17 @@ -import { Component, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { NoResults } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "../icon/icon.component"; +import { SvgComponent } from "../svg/svg.component"; /** * Component for displaying a message when there are no items to display. Expects title, description and button slots. */ -// 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-no-items", templateUrl: "./no-items.component.html", - imports: [BitIconComponent], + imports: [SvgComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class NoItemsComponent { readonly icon = input(NoResults); diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts index c4fe2f9b2af..398251fd2e2 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts @@ -16,7 +16,6 @@ import { DialogModule } from "../../dialog"; import { DrawerModule } from "../../drawer"; import { FormControlModule } from "../../form-control"; import { FormFieldModule } from "../../form-field"; -import { IconModule } from "../../icon"; import { IconButtonModule } from "../../icon-button"; import { InputModule } from "../../input"; import { LayoutComponent } from "../../layout"; @@ -31,6 +30,7 @@ import { SearchModule } from "../../search"; import { SectionComponent } from "../../section"; import { SelectModule } from "../../select"; import { SharedModule } from "../../shared"; +import { SvgModule } from "../../svg"; import { TableModule } from "../../table"; import { TabsModule } from "../../tabs"; import { ToggleGroupModule } from "../../toggle-group"; @@ -54,7 +54,7 @@ import { TypographyModule } from "../../typography"; FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, @@ -92,7 +92,7 @@ import { TypographyModule } from "../../typography"; FormFieldModule, FormsModule, IconButtonModule, - IconModule, + SvgModule, InputModule, LayoutComponent, LinkModule, diff --git a/libs/components/src/svg/index.ts b/libs/components/src/svg/index.ts new file mode 100644 index 00000000000..ae4c480e786 --- /dev/null +++ b/libs/components/src/svg/index.ts @@ -0,0 +1,2 @@ +export * from "./svg.module"; +export * from "./svg.component"; diff --git a/libs/components/src/svg/svg.component.ts b/libs/components/src/svg/svg.component.ts new file mode 100644 index 00000000000..bcb63cfa568 --- /dev/null +++ b/libs/components/src/svg/svg.component.ts @@ -0,0 +1,31 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; + +import { BitSvg, isBitSvg } from "@bitwarden/assets/svg"; + +@Component({ + selector: "bit-svg", + host: { + "[attr.aria-hidden]": "!ariaLabel()", + "[attr.aria-label]": "ariaLabel()", + "[innerHtml]": "innerHtml()", + class: "tw-max-h-full tw-flex tw-justify-center", + }, + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SvgComponent { + private domSanitizer = inject(DomSanitizer); + + readonly content = input(); + readonly ariaLabel = input(); + + protected readonly innerHtml = computed(() => { + const content = this.content(); + if (!isBitSvg(content)) { + return null; + } + const svg = content.svg; + return this.domSanitizer.bypassSecurityTrustHtml(svg); + }); +} diff --git a/libs/components/src/icon/icon.components.spec.ts b/libs/components/src/svg/svg.components.spec.ts similarity index 55% rename from libs/components/src/icon/icon.components.spec.ts rename to libs/components/src/svg/svg.components.spec.ts index 3ae37ff5423..55874d29e6c 100644 --- a/libs/components/src/icon/icon.components.spec.ts +++ b/libs/components/src/svg/svg.components.spec.ts @@ -1,25 +1,25 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Icon, svgIcon } from "@bitwarden/assets/svg"; +import { BitSvg, svg } from "@bitwarden/assets/svg"; -import { BitIconComponent } from "./icon.component"; +import { SvgComponent } from "./svg.component"; -describe("IconComponent", () => { - let fixture: ComponentFixture; +describe("SvgComponent", () => { + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BitIconComponent], + imports: [SvgComponent], }).compileComponents(); - fixture = TestBed.createComponent(BitIconComponent); + fixture = TestBed.createComponent(SvgComponent); fixture.detectChanges(); }); it("should have empty innerHtml when input is not an Icon", () => { - const fakeIcon = { svg: "harmful user input" } as Icon; + const fakeIcon = { svg: "harmful user input" } as BitSvg; - fixture.componentRef.setInput("icon", fakeIcon); + fixture.componentRef.setInput("content", fakeIcon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; @@ -27,9 +27,9 @@ describe("IconComponent", () => { }); it("should contain icon when input is a safe Icon", () => { - const icon = svgIcon`safe icon`; + const icon = svg`safe icon`; - fixture.componentRef.setInput("icon", icon); + fixture.componentRef.setInput("content", icon); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; diff --git a/libs/components/src/svg/svg.mdx b/libs/components/src/svg/svg.mdx new file mode 100644 index 00000000000..a29a6f86b14 --- /dev/null +++ b/libs/components/src/svg/svg.mdx @@ -0,0 +1,120 @@ +import { Meta, Story, Controls } from "@storybook/addon-docs/blocks"; + +import * as stories from "./svg.stories"; + + + +```ts +import { SvgModule } from "@bitwarden/components"; +``` + +# Svg Use Instructions + +- Icons will generally be attached to the associated Jira task. + - Designers should minify any SVGs before attaching them to Jira using a tool like + [SVGOMG](https://jakearchibald.github.io/svgomg/). + - **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon + is desired. + +## Developer Instructions + +1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice. + - The SVG should be formatted using either a built-in formatter or an external tool like + [SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying + classes easier. + +2. **Rename the file** as a `.icon.ts` TypeScript file and place it in the `libs/assets/svg` + lib. + +3. **Import** `svg` from `./svg`. + +4. **Define and export** a `const` to represent your `svg`. + + ```typescript + export const ExampleIcon = svg``; + ``` + +5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class. + - **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when + styling the inside of an SVG path. + + - A non-comprehensive list of common colors and their associated classes is below: + + | Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable | + | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- | + | `#020F66` | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` | + | `#DBE5F6` | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` | + | `#AAC3EF` | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` | + | `#FFFFFF` | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` | + | `#FFBF00` | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` | + | `#175DDC` | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` | + + - If the hex that you have on an SVG path is not listed above, there are a few ways to figure out + the appropriate Tailwind class: + - **Option 1: Figma** + - Open the SVG in Figma. + - Click on an individual path on the SVG until you see the path's properties in the + right-hand panel. + - Scroll down to the Colors section. + - Example: `Color/Illustration/Outline` + - This also includes Hex or RGB values that can be used to find the appropriate Tailwind + variable as well if you follow the manual search option below. + - Create the appropriate stroke or fill class from the color used. + - Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which + corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`. + - **Option 2: Manual Search** + - Take the path's stroke or fill hex value and convert it to RGB using a tool like + [Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/). + - Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable + that corresponds to the color. + - Create the appropriate stroke or fill class using the Tailwind variable. + - Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline` + or `tw-fill-illustration-outline`. + +6. **Remove any hardcoded width or height attributes** if your SVG has a configured + [viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order + to allow the SVG to scale to fit its container. + - **Note:** Scaling is required for any SVG used as an + [AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`. + +7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the + referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`). + +8. **Import your SVG const** anywhere you want to use the SVG. + - **Angular Component Example:** + - **TypeScript:** + + ```typescript + import { Component } from "@angular/core"; + import { SvgModule } from '@bitwarden/components'; + import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg"; + + @Component({ + selector: "app-example", + standalone: true, + imports: [SvgModule], + templateUrl: "./example.component.html", + }) + export class ExampleComponent { + readonly Icons = { ExampleIcon, Example2Icon }; + ... + } + ``` + + - **HTML:** + + > NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an + > `ariaLabel` is explicitly provided to the `` component + + ```html + + ``` + + With `ariaLabel` + + ```html + + ``` + +9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client + which supports multiple style modes. diff --git a/libs/components/src/svg/svg.module.ts b/libs/components/src/svg/svg.module.ts new file mode 100644 index 00000000000..c1cdae0e232 --- /dev/null +++ b/libs/components/src/svg/svg.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { SvgComponent } from "./svg.component"; + +@NgModule({ + imports: [SvgComponent], + exports: [SvgComponent], +}) +export class SvgModule {} diff --git a/libs/components/src/svg/svg.stories.ts b/libs/components/src/svg/svg.stories.ts new file mode 100644 index 00000000000..b2eb10771ce --- /dev/null +++ b/libs/components/src/svg/svg.stories.ts @@ -0,0 +1,50 @@ +import { Meta } from "@storybook/angular"; + +import * as SvgIcons from "@bitwarden/assets/svg"; + +import { SvgComponent } from "./svg.component"; + +export default { + title: "Component Library/Svg", + component: SvgComponent, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11", + }, + }, +} as Meta; + +const { + // Filtering out the few non-icons in the libs/assets/svg import + // eslint-disable-next-line @typescript-eslint/no-unused-vars + DynamicContentNotAllowedError: _DynamicContentNotAllowedError, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isBitSvg, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + svg, + ...Icons +}: { + [key: string]: any; +} = SvgIcons; + +export const Default = { + render: (args: { icons: [string, any][] }) => ({ + props: args, + template: /*html*/ ` +
    + @for (icon of icons; track icon[0]) { +
    +
    {{icon[0]}}
    +
    + +
    +
    + } +
    + `, + }), + args: { + icons: Object.entries(Icons), + }, +}; diff --git a/libs/eslint/components/index.mjs b/libs/eslint/components/index.mjs index 273c29890fe..101fdde414c 100644 --- a/libs/eslint/components/index.mjs +++ b/libs/eslint/components/index.mjs @@ -1,9 +1,11 @@ import requireLabelOnBiticonbutton from "./require-label-on-biticonbutton.mjs"; import requireThemeColorsInSvg from "./require-theme-colors-in-svg.mjs"; +import noBwiClassUsage from "./no-bwi-class-usage.mjs"; export default { rules: { "require-label-on-biticonbutton": requireLabelOnBiticonbutton, "require-theme-colors-in-svg": requireThemeColorsInSvg, + "no-bwi-class-usage": noBwiClassUsage, }, }; diff --git a/libs/eslint/components/no-bwi-class-usage.mjs b/libs/eslint/components/no-bwi-class-usage.mjs new file mode 100644 index 00000000000..8260587ce45 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.mjs @@ -0,0 +1,45 @@ +export const errorMessage = + "Use component instead of applying 'bwi' classes directly. Example: "; + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Discourage using 'bwi' font icon classes directly in favor of the component", + category: "Best Practices", + recommended: true, + }, + schema: [], + }, + create(context) { + return { + Element(node) { + // Get all class-related attributes + const classAttrs = [ + ...(node.attributes?.filter((attr) => attr.name === "class") ?? []), + ...(node.inputs?.filter((input) => input.name === "class") ?? []), + ...(node.templateAttrs?.filter((attr) => attr.name === "class") ?? []), + ]; + + for (const classAttr of classAttrs) { + const classValue = classAttr.value || ""; + + // Check if the class value contains 'bwi' or 'bwi-' + // This handles both string literals and template expressions + const hasBwiClass = + typeof classValue === "string" && /\bbwi(?:-[\w-]+)?\b/.test(classValue); + + if (hasBwiClass) { + context.report({ + node, + message: errorMessage, + }); + // Only report once per element + break; + } + } + }, + }; + }, +}; diff --git a/libs/eslint/components/no-bwi-class-usage.spec.mjs b/libs/eslint/components/no-bwi-class-usage.spec.mjs new file mode 100644 index 00000000000..abb5ebe3b29 --- /dev/null +++ b/libs/eslint/components/no-bwi-class-usage.spec.mjs @@ -0,0 +1,44 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; + +import rule, { errorMessage } from "./no-bwi-class-usage.mjs"; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: require("@angular-eslint/template-parser"), + }, +}); + +ruleTester.run("no-bwi-class-usage", rule.default, { + valid: [ + { + name: "should allow bit-icon component usage", + code: ``, + }, + { + name: "should allow elements without bwi classes", + code: `
    `, + }, + ], + invalid: [ + { + name: "should error on direct bwi class usage", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi class with other classes", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on single bwi-* class", + code: ``, + errors: [{ message: errorMessage }], + }, + { + name: "should error on bwi-fw modifier", + code: ``, + errors: [{ message: errorMessage }], + }, + ], +}); diff --git a/libs/eslint/components/require-theme-colors-in-svg.mjs b/libs/eslint/components/require-theme-colors-in-svg.mjs index fcc9cba461c..d30840710ca 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.mjs @@ -25,7 +25,7 @@ export default { tagNames: { type: "array", items: { type: "string" }, - default: ["svgIcon"], + default: ["svg"], }, }, additionalProperties: false, @@ -35,7 +35,7 @@ export default { create(context) { const options = context.options[0] || {}; - const tagNames = options.tagNames || ["svgIcon"]; + const tagNames = options.tagNames || ["svg"]; function isSvgTaggedTemplate(node) { return ( diff --git a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs index fd513ba57b3..f51871fdc9a 100644 --- a/libs/eslint/components/require-theme-colors-in-svg.spec.mjs +++ b/libs/eslint/components/require-theme-colors-in-svg.spec.mjs @@ -17,36 +17,36 @@ ruleTester.run("require-theme-colors-in-svg", rule.default, { valid: [ { name: "Allows fill=none", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows CSS variable", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, { name: "Allows class-based coloring", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', }, ], invalid: [ { name: "Errors on fill with hex color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "#000000" } }], }, { name: "Errors on stroke with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "red" } }], }, { name: "Errors on fill with rgb()", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "rgb(255,0,0)" } }], }, { name: "Errors on fill with named color", - code: 'const icon = svgIcon``;', + code: 'const icon = svg``;', errors: [{ messageId: "hardcodedColor", data: { color: "blue" } }], }, ], diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index 735d694152c..669b54c5b57 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BadgeVariant, ButtonType, IconModule, TypographyModule } from "@bitwarden/components"; +import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; @Component({ @@ -68,13 +68,7 @@ describe("PricingCardComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [ - PricingCardComponent, - TestHostComponent, - IconModule, - TypographyModule, - CommonModule, - ], + imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule], }).compileComponents(); // For signal inputs, we need to set required inputs through the host component diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index c9da7c32462..4b9241fc9dd 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -7,7 +7,7 @@ import { ButtonModule, ButtonType, CardComponent, - IconModule, + SvgModule, TypographyModule, } from "@bitwarden/components"; @@ -20,7 +20,7 @@ import { selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BadgeModule, ButtonModule, IconModule, TypographyModule, CurrencyPipe, CardComponent], + imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent], }) export class PricingCardComponent { readonly tagline = input.required(); diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html index 7af120cfd6c..913d1b7963b 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html @@ -9,5 +9,5 @@ [attr.aria-label]="slide.label" (click)="onClick.emit()" > - + diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts index bef7f5b12d6..42fe082d5f8 100644 --- a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; import { CarouselIcon } from "@bitwarden/assets/svg"; -import { IconModule } from "@bitwarden/components"; +import { SvgModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component"; @@ -12,7 +12,7 @@ import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.co @Component({ selector: "vault-carousel-button", templateUrl: "carousel-button.component.html", - imports: [CommonModule, IconModule], + imports: [CommonModule, SvgModule], }) export class VaultCarouselButtonComponent implements FocusableOption { /** Slide component that is associated with the individual button */ From 136705ac081684c993d3d32ad8da69920b7b72fe Mon Sep 17 00:00:00 2001 From: Jared Date: Wed, 28 Jan 2026 12:27:16 -0500 Subject: [PATCH 059/130] Refactor autofill policy naming and update related translations (#18628) - Renamed `activateAutofill` to `activateAutofillPolicy` in the policy order map and component. - Updated corresponding translation keys in `messages.json` for consistency. - Adjusted warning message in the `activate-autofill.component.html` to reflect the new naming convention. --- .../policies/pipes/policy-order.pipe.ts | 2 +- apps/web/src/locales/en/messages.json | 12 ++++++------ .../activate-autofill.component.html | 4 ++-- .../activate-autofill.component.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts index ec9fef23b9d..02092f05b92 100644 --- a/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts +++ b/apps/web/src/app/admin-console/organizations/policies/pipes/policy-order.pipe.ts @@ -20,7 +20,7 @@ const POLICY_ORDER_MAP = new Map([ ["removeUnlockWithPinPolicyTitle", 10], ["passwordGenerator", 11], ["uriMatchDetectionPolicy", 12], - ["activateAutofill", 13], + ["activateAutofillPolicy", 13], ["sendOptions", 14], ["disableSend", 15], ["restrictedItemTypePolicy", 16], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3ba1ffc910b..ecb5f8d2dfc 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6937,17 +6937,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, "activateAutofillPolicyDescription": { "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html index e2dbc8e8326..32ac2e229a9 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.html @@ -1,11 +1,11 @@ - {{ "experimentalFeature" | i18n }} + {{ "autofillOnPageLoadExploitWarning" | i18n }} {{ "learnMoreAboutAutofill" | i18n }}{{ "learnMoreAboutAutofillPolicy" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts index 03eb189741c..984a3dc1aff 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/activate-autofill.component.ts @@ -11,7 +11,7 @@ import { import { SharedModule } from "@bitwarden/web-vault/app/shared"; export class ActivateAutofillPolicy extends BasePolicyEditDefinition { - name = "activateAutofill"; + name = "activateAutofillPolicy"; description = "activateAutofillPolicyDescription"; type = PolicyType.ActivateAutofill; component = ActivateAutofillPolicyComponent; From c07beb3b10894bd841c542bfe4e11d72a10ffac1 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 28 Jan 2026 09:38:15 -0800 Subject: [PATCH 060/130] [PM-31282] Pass orgId through to API call when SDK feature flag is off (#18619) --- libs/common/src/vault/services/cipher.service.spec.ts | 2 +- libs/common/src/vault/services/cipher.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 07444d5d1c6..28b1f064d89 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1209,7 +1209,7 @@ describe("Cipher Service", () => { await cipherService.softDeleteManyWithServer(testCipherIds, userId, true, orgId); - expect(apiSpy).toHaveBeenCalled(); + expect(apiSpy).toHaveBeenCalledWith({ ids: testCipherIds, organizationId: orgId }); }); it("should use SDK to soft delete multiple ciphers when feature flag is enabled", async () => { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 1fc455a1ae9..81060870e8b 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1619,7 +1619,7 @@ export class CipherService implements CipherServiceAbstraction { return; } - const request = new CipherBulkDeleteRequest(ids); + const request = new CipherBulkDeleteRequest(ids, orgId); if (asAdmin) { await this.apiService.putDeleteManyCiphersAdmin(request); } else { From bddd6f5fb1e23211583e0e0f0c07508a8493e430 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 28 Jan 2026 13:31:49 -0500 Subject: [PATCH 061/130] [PM-31253] Desktop Footer Tooltip Updates (#18580) * update desktop archive and delete btns so tooltip shows on hover consistently. --- .../app/vault/item-footer.component.html | 109 +++++++++--------- 1 file changed, 57 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html index 0af73bf7d8a..5e3de1e6a14 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.html +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -11,61 +11,66 @@ > {{ submitButtonText() }} - - - + @if (!cipher.isDeleted && action === "view") { + + } + + @if (action === "edit" || action === "clone" || action === "add") { + + } + + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + @if (showCloneOption) { } -
    - - - -
    + @if (hasFooterAction) { +
    + @if (showArchiveButton) { + + } + + @if (showUnarchiveButton) { + + } + + +
    + }

    From 3632afd26e56d06493ecd70eb8773f9c294b2fda Mon Sep 17 00:00:00 2001 From: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:48:58 +0100 Subject: [PATCH 062/130] Remove `ts-strict-ignore` from fido2 page (#18146) * Remove `ts-strict-ignore` from fido2 page * Update typing issue * Fix AssertCredentialResult type issue * Remove non null assertions and add type guard * Addresses topWindow non null assertion * remove redundant check and remove ts strict from messenger --------- Co-authored-by: Jonathan Prusik Co-authored-by: Daniel Riera --- .../fido2/content/fido2-page-script.ts | 49 +++++++++++++------ .../fido2/content/messaging/messenger.ts | 6 +-- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts index 1cd614a9516..d55e0827352 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script.ts @@ -1,12 +1,10 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { WebauthnUtils } from "../utils/webauthn-utils"; import { MessageTypes } from "./messaging/message"; import { Messenger } from "./messaging/messenger"; (function (globalContext) { - if (globalContext.document.currentScript) { + if (globalContext.document.currentScript?.parentNode) { globalContext.document.currentScript.parentNode.removeChild( globalContext.document.currentScript, ); @@ -86,7 +84,7 @@ import { Messenger } from "./messaging/messenger"; */ async function createWebAuthnCredential( options?: CredentialCreationOptions, - ): Promise { + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.create(options); } @@ -106,13 +104,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialCreationResponse) { + if (response.type !== MessageTypes.CredentialCreationResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialRegistrationResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.create(options); } @@ -127,7 +130,9 @@ import { Messenger } from "./messaging/messenger"; * @param options Options for creating new credentials. * @returns Promise that resolves to the new credential object. */ - async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise { + async function getWebAuthnCredential( + options?: CredentialRequestOptions, + ): Promise { if (!isWebauthnCall(options)) { return await browserCredentials.get(options); } @@ -153,7 +158,7 @@ import { Messenger } from "./messaging/messenger"; internalAbortController.signal, ); internalAbortController.signal.removeEventListener("abort", abortListener); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } @@ -176,7 +181,7 @@ import { Messenger } from "./messaging/messenger"; abortSignal.removeEventListener("abort", abortListener); internalAbortControllers.forEach((controller) => controller.abort()); - return response; + return response ?? null; } try { @@ -188,13 +193,18 @@ import { Messenger } from "./messaging/messenger"; options?.signal, ); - if (response.type !== MessageTypes.CredentialGetResponse) { + if (response.type !== MessageTypes.CredentialGetResponse || !response.result) { throw new Error("Something went wrong."); } return WebauthnUtils.mapCredentialAssertResult(response.result); } catch (error) { - if (error && error.fallbackRequested && fallbackSupported) { + if ( + fallbackSupported && + error instanceof Object && + "fallbackRequested" in error && + error.fallbackRequested + ) { await waitForFocus(); return await browserCredentials.get(options); } @@ -203,8 +213,10 @@ import { Messenger } from "./messaging/messenger"; } } - function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) { - return options && "publicKey" in options; + function isWebauthnCall( + options?: CredentialCreationOptions | CredentialRequestOptions, + ): options is CredentialCreationOptions | CredentialRequestOptions { + return options != null && "publicKey" in options; } /** @@ -217,7 +229,7 @@ import { Messenger } from "./messaging/messenger"; */ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) { try { - if (globalContext.top.document.hasFocus()) { + if (globalContext.top?.document.hasFocus()) { return; } } catch { @@ -225,9 +237,14 @@ import { Messenger } from "./messaging/messenger"; return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); } + if (!globalContext.top) { + return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait)); + } + + const topWindow = globalContext.top; const focusPromise = new Promise((resolve) => { focusListenerHandler = () => resolve(); - globalContext.top.addEventListener("focus", focusListenerHandler); + topWindow.addEventListener("focus", focusListenerHandler); }); const timeoutPromise = new Promise((_, reject) => { @@ -248,7 +265,7 @@ import { Messenger } from "./messaging/messenger"; } function clearWaitForFocus() { - globalContext.top.removeEventListener("focus", focusListenerHandler); + globalContext.top?.removeEventListener("focus", focusListenerHandler); if (waitForFocusTimeout) { globalContext.clearTimeout(waitForFocusTimeout); } diff --git a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts index 257f7e9efd5..78bb9aa8f33 100644 --- a/apps/browser/src/autofill/fido2/content/messaging/messenger.ts +++ b/apps/browser/src/autofill/fido2/content/messaging/messenger.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Message, MessageTypes } from "./message"; const SENDER = "bitwarden-webauthn"; @@ -25,7 +23,9 @@ type Handler = ( * handling aborts and exceptions across separate execution contexts. */ export class Messenger { - private messageEventListener: (event: MessageEvent) => void | null = null; + private messageEventListener: + | ((event: MessageEvent) => void | Promise) + | null = null; private onDestroy = new EventTarget(); /** From c5bd811dfd4267665fd84f9a6cbdd926517dcc17 Mon Sep 17 00:00:00 2001 From: Alex Dragovich <46065570+itsadrago@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:49:20 -0800 Subject: [PATCH 063/130] [PM-31323] change text on toast for send link copy (#18617) --- .../components/send-details/send-details.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index 6d42cca2186..581ee20caf7 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -30,6 +30,7 @@ showToast bitIconButton="bwi-clone" [appCopyClick]="sendLink" + [valueLabel]="'sendLink' | i18n" [label]="'copySendLink' | i18n" > From d40e9a36443f49bbd90389539ee3f190f53b56a4 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:47:38 -0800 Subject: [PATCH 064/130] [PM-30918] Migrate DIRT components to new Angular control flow syntax (#18416) * dirt: migrate apps/web components to new control flow * dirt: update control flow bitwarden licensed code * consolidate @if statements, use @else where appropriate * more cleanup * consolidate conditionals * remove unnecessary conditional --- .../pages/breach-report.component.html | 91 +++---- .../exposed-passwords-report.component.html | 217 +++++++++-------- .../inactive-two-factor-report.component.html | 229 +++++++++--------- .../reused-passwords-report.component.html | 216 +++++++++-------- .../unsecured-websites-report.component.html | 204 ++++++++-------- .../weak-passwords-report.component.html | 228 ++++++++--------- .../report-list/report-list.component.html | 20 +- .../activity/activity-card.component.ts | 3 +- .../password-change-metric.component.ts | 3 +- .../assign-tasks-view.component.ts | 2 - .../new-applications-dialog.component.ts | 2 - .../empty-state-card.component.html | 134 +++++----- .../empty-state-card.component.ts | 3 +- .../risk-insights.component.html | 17 +- .../app-table-row-scrollable.component.html | 98 ++++---- .../shared/report-loading.component.ts | 3 +- .../integration-grid.component.html | 35 +-- .../integrations.component.html | 48 ++-- .../member-access-report.component.html | 84 ++++--- 19 files changed, 838 insertions(+), 799 deletions(-) diff --git a/apps/web/src/app/dirt/reports/pages/breach-report.component.html b/apps/web/src/app/dirt/reports/pages/breach-report.component.html index d645fa39d69..0915902143e 100644 --- a/apps/web/src/app/dirt/reports/pages/breach-report.component.html +++ b/apps/web/src/app/dirt/reports/pages/breach-report.component.html @@ -12,45 +12,54 @@ {{ "checkBreaches" | i18n }} -
    -

    {{ "reportError" | i18n }}...

    - - - {{ "breachUsernameNotFound" | i18n: checkedUsername }} - - - {{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }} - -
      -
    • -
      - -
      -
      -

      {{ a.title }}

      -

      -

      {{ "compromisedData" | i18n }}:

      -
        -
      • {{ d }}
      • -
      -
      -
      -
      -
      {{ "website" | i18n }}
      -
      {{ a.domain }}
      -
      {{ "affectedUsers" | i18n }}
      -
      {{ a.pwnCount | number }}
      -
      {{ "breachOccurred" | i18n }}
      -
      {{ a.breachDate | date: "mediumDate" }}
      -
      {{ "breachReported" | i18n }}
      -
      {{ a.addedDate | date: "mediumDate" }}
      -
      -
      -
    • -
    -
    -
    + @if (!loading && checkedUsername) { +
    + @if (error) { +

    {{ "reportError" | i18n }}...

    + } @else { + @if (!breachedAccounts.length) { + + {{ "breachUsernameNotFound" | i18n: checkedUsername }} + + } @else { + + {{ "breachUsernameFound" | i18n: checkedUsername : breachedAccounts.length }} + +
      + @for (a of breachedAccounts; track a) { +
    • +
      + +
      +
      +

      {{ a.title }}

      +

      +

      {{ "compromisedData" | i18n }}:

      +
        + @for (d of a.dataClasses; track d) { +
      • {{ d }}
      • + } +
      +
      +
      +
      +
      {{ "website" | i18n }}
      +
      {{ a.domain }}
      +
      {{ "affectedUsers" | i18n }}
      +
      {{ a.pwnCount | number }}
      +
      {{ "breachOccurred" | i18n }}
      +
      {{ a.breachDate | date: "mediumDate" }}
      +
      {{ "breachReported" | i18n }}
      +
      {{ a.addedDate | date: "mediumDate" }}
      +
      +
      +
    • + } +
    + } + } +
    + } 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 55e6678bd58..ba118ea6663 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 @@ -5,108 +5,119 @@ -
    - - {{ "noExposedPasswords" | i18n }} - - - - {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "timesExposed" | i18n }} - - - - - - - - - - {{ row.name }} - - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + @if (!ciphers.length) { + + {{ "noExposedPasswords" | i18n }} + + } @else { + + {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} - - -
    -
    -
    -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + + {{ "owner" | i18n }} + + } + + {{ "timesExposed" | i18n }} + + + + + + + + @if (!organization || canManageCipher(row)) { + + {{ row.name }} + + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } + + + {{ "exposedXTimes" | i18n: (row.exposedXTimes | number) }} + + +
    +
    + } +
    + } 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 a1d3f2a38be..4999d572969 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 @@ -2,117 +2,124 @@

    {{ "inactive2faReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noInactive2fa" | i18n }} - - - - {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - + @if (!hasLoaded && loading) { +
    + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noInactive2fa" | i18n }} + + } @else { + + {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + + + } + + + + + + @if (!organization || canManageCipher(row)) { + + {{ row.name }} + + } @else { + + {{ row.name }} + + } + @if (!organization && row.organizationId) { + + + {{ "shared" | i18n }} + + } + @if (row.hasAttachments) { + + + {{ "attachments" | i18n }} + + } +
    + {{ row.subTitle }} + + + @if (!organization) { + + } + + + @if (cipherDocs.has(row.id)) { + + {{ "instructions" | i18n }} + } + +
    +
    } - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - - - - - - {{ "instructions" | i18n }} - -
    -
    - -
    +
    + }
    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 62496dfad00..f08af8bda01 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 @@ -2,111 +2,115 @@

    {{ "reusedPasswordsReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noReusedPasswords" | i18n }} - - - - {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - {{ "timesReused" | i18n }} - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noReusedPasswords" | i18n }} + + } @else { + + {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} - - - - - -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + {{ "timesReused" | i18n }} + + } + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + + @if (!organization) { + + + } + + + + {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} + + +
    +
    + } +
    + } 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 276508b3801..810c1e384b0 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 @@ -2,105 +2,109 @@

    {{ "unsecuredWebsitesReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noUnsecuredWebsites" | i18n }} - - - - {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noUnsecuredWebsites" | i18n }} + + } @else { + + {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + @if (!isAdminConsoleActive) { + + + {{ "name" | i18n }} + {{ "owner" | i18n }} + + } + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + + @if (!organization) { + + + } + +
    +
    + } +
    + } 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 96bae4c3e0a..d96d083ffe0 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 @@ -2,115 +2,123 @@

    {{ "weakPasswordsReportDesc" | i18n }}

    -
    - - {{ "loading" | i18n }} -
    -
    - - {{ "noWeakPasswords" | i18n }} - - - - {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} - - - @if (showFilterToggle && !isAdminConsoleActive) { - @if (canDisplayToggleGroup()) { - - - - {{ getName(status) }} - {{ getCount(status) }} - - - - } @else { - - } - } - - - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "weakness" | i18n }} - - - - - - - - - {{ row.name }} - - - {{ row.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
    - {{ row.subTitle }} - - - + + {{ "loading" | i18n }} +
    + } @else { +
    + @if (!ciphers.length) { + + {{ "noWeakPasswords" | i18n }} + + } @else { + + {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }} + + @if (showFilterToggle && !isAdminConsoleActive) { + @if (canDisplayToggleGroup()) { + - - - - - {{ row.reportValue.label | i18n }} - - - - - -
    + @for (status of filterStatus; track status) { + + {{ getName(status) }} + {{ getCount(status) }} + + } + + } @else { + + } + } + + + + {{ "name" | i18n }} + @if (!isAdminConsoleActive) { + + {{ "owner" | i18n }} + + } + + {{ "weakness" | i18n }} + + + + + + + + @if (!organization || canManageCipher(row)) { + {{ row.name }} + } @else { + {{ row.name }} + } + @if (!organization && row.organizationId) { + + {{ "shared" | i18n }} + } + @if (row.hasAttachments) { + + {{ "attachments" | i18n }} + } +
    + {{ row.subTitle }} + + @if (!isAdminConsoleActive) { + + @if (!organization) { + + + } + + } + + + {{ row.reportValue.label | i18n }} + + +
    +
    + } +
    + } diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html index 2a03bf78dd4..bba57882027 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html @@ -1,13 +1,15 @@
    -
    - -
    + @for (report of reports; track report) { +
    + +
    + }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts index e7c54bc81d0..111cf3e4d01 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Router } from "@angular/router"; @@ -10,7 +9,7 @@ import { ButtonModule, ButtonType, LinkModule, TypographyModule } from "@bitward @Component({ selector: "dirt-activity-card", templateUrl: "./activity-card.component.html", - imports: [CommonModule, TypographyModule, JslibModule, LinkModule, ButtonModule], + imports: [TypographyModule, JslibModule, LinkModule, ButtonModule], host: { class: "tw-box-border tw-bg-background tw-block tw-text-main tw-border-solid tw-border tw-border-secondary-300 tw-border [&:not(bit-layout_*)]:tw-rounded-lg tw-rounded-lg tw-p-6 tw-min-h-56 tw-overflow-hidden", diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 30e1db7b438..60b53f7405d 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, @@ -44,7 +43,7 @@ export type PasswordChangeView = (typeof PasswordChangeView)[keyof typeof Passwo @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-password-change-metric", - imports: [CommonModule, TypographyModule, JslibModule, ProgressModule, ButtonModule], + imports: [TypographyModule, JslibModule, ProgressModule, ButtonModule], templateUrl: "./password-change-metric.component.html", }) export class PasswordChangeMetricComponent implements OnInit { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts index 15d927a7714..619858fdffe 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { @@ -25,7 +24,6 @@ import { DarkImageSourceDirective } from "@bitwarden/vault"; selector: "dirt-assign-tasks-view", templateUrl: "./assign-tasks-view.component.html", imports: [ - CommonModule, ButtonModule, TypographyModule, I18nPipe, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts index 4de8ecd9cd0..796c0acf220 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, @@ -79,7 +78,6 @@ export type NewApplicationsDialogResultType = selector: "dirt-new-applications-dialog", templateUrl: "./new-applications-dialog.component.html", imports: [ - CommonModule, ButtonModule, DialogModule, TypographyModule, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html index b1eda08481a..59aa680fa4e 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.html @@ -6,12 +6,11 @@ {{ title() }}

    -
    - {{ description() }} -
    + @if (description()) { +
    + {{ description() }} +
    + } @if (benefits().length > 0) {
    @for (benefit of benefits(); track $index) { @@ -38,69 +37,74 @@
    } -
    - -
    + @if (buttonText() && buttonAction()) { +
    + +
    + }
    -
    -
    - @if (videoSrc()) { - - } @else if (icon()) { -
    - +
    + @if (videoSrc()) { +
    - } + > + } @else if (icon()) { +
    + +
    + } +
    -
    - -
    -
    - @if (videoSrc()) { - - } @else if (icon()) { -
    - +
    + @if (videoSrc()) { +
    - } + > + } @else if (icon()) { +
    + +
    + } +
    -
    + }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts index c28de5e9952..a9ad86dc67c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; import { BitSvg } from "@bitwarden/assets/svg"; @@ -7,7 +6,7 @@ import { ButtonModule, SvgModule } from "@bitwarden/components"; @Component({ selector: "empty-state-card", templateUrl: "./empty-state-card.component.html", - imports: [CommonModule, SvgModule, ButtonModule], + imports: [SvgModule, ButtonModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EmptyStateCardComponent implements OnInit { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index dfbd49d95f7..2a783e6dcc2 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -44,10 +44,11 @@
    -
    - {{ "reviewAtRiskPasswords" | i18n }} -
    - @let isRunningReport = dataService.isGeneratingReport$ | async; + @if (appsCount > 0) { +
    + {{ "reviewAtRiskPasswords" | i18n }} +
    + }
    @@ -62,7 +63,6 @@ } - - -
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html index 0494f77bd46..0a72c76a550 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html @@ -12,28 +12,32 @@ {{ "totalMembers" | i18n }} - - - - - - - + @if (showRowCheckBox) { + + @if (!row.isMarkedAsCritical) { + + } + @if (row.isMarkedAsCritical) { + + } + + } + @if (!showRowCheckBox) { + + @if (row.isMarkedAsCritical) { + + } + + } - + @if (row.iconCipher) { + + } {{ row.memberCount }} - - - - - - - + @if (showRowMenuForCriticalApps) { + + + + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts index f3cb89dff55..45b28dae470 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/report-loading.component.ts @@ -1,4 +1,3 @@ -import { CommonModule } from "@angular/common"; import { Component, input } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -19,7 +18,7 @@ const ProgressStepConfig = Object.freeze({ // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "dirt-report-loading", - imports: [CommonModule, JslibModule, ProgressModule], + imports: [JslibModule, ProgressModule], templateUrl: "./report-loading.component.html", }) export class ReportLoadingComponent { diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html index 9e14023d21b..8127c6a0343 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integration-grid/integration-grid.component.html @@ -1,21 +1,22 @@
      -
    • - -
    • + @for (integration of integrations; track integration) { +
    • + +
    • + }
    diff --git a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html index a35df3677bb..14f20a0b71c 100644 --- a/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/organization-integrations/integrations.component.html @@ -24,28 +24,32 @@ @if (organization?.useScim || organization?.useDirectory) { -
    -

    - {{ "scimIntegration" | i18n }} -

    -

    - {{ "scimIntegrationDescStart" | i18n }} - {{ "scimIntegration" | i18n }} - {{ "scimIntegrationDescEnd" | i18n }} -

    - -
    -
    -

    - {{ "bwdc" | i18n }} -

    -

    {{ "bwdcDesc" | i18n }}

    - -
    + @if (organization?.useScim) { +
    +

    + {{ "scimIntegration" | i18n }} +

    +

    + {{ "scimIntegrationDescStart" | i18n }} + {{ "scimIntegration" | i18n }} + {{ "scimIntegrationDescEnd" | i18n }} +

    + +
    + } + @if (organization?.useDirectory) { +
    +

    + {{ "bwdc" | i18n }} +

    +

    {{ "bwdcDesc" | i18n }}

    + +
    + }
    } diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html index 0200e206327..440e955a226 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.html @@ -1,21 +1,17 @@ - + @let isLoading = isLoading$ | async; - + @if (!isLoading) { + + + }
    @@ -24,7 +20,7 @@

    - +@if (isLoading) {

    {{ "loading" | i18n }}

    -
    - - - {{ "members" | i18n }} - {{ "groups" | i18n }} - {{ "collections" | i18n }} - {{ "items" | i18n }} - - - -
    - -
    - - -
    - {{ row.email }} +} @else { + + + {{ "members" | i18n }} + {{ "groups" | i18n }} + + {{ "collections" | i18n }} + + {{ "items" | i18n }} + + + +
    + +
    + +
    + {{ row.email }} +
    -
    - - {{ row.groupsCount }} - {{ row.collectionsCount }} - {{ row.itemsCount }} - - + + {{ row.groupsCount }} + {{ row.collectionsCount }} + {{ row.itemsCount }} + + +} From fa5f62e1bd9e00c17e8e216bc85b317f1f065d35 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Wed, 28 Jan 2026 16:00:56 -0500 Subject: [PATCH 065/130] Revert "[PM-26821] Improve macOS fullscreen ux (#16838)" (#18606) This reverts commit 05ca57d538240d48cc28553e9f2dafe95b717a5a. --- .../browser/browser-popup-utils.spec.ts | 64 ------------------- .../platform/browser/browser-popup-utils.ts | 23 +------ 2 files changed, 1 insertion(+), 86 deletions(-) diff --git a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts index cb04f30b589..89459523843 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.spec.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.spec.ts @@ -140,11 +140,6 @@ describe("BrowserPopupUtils", () => { describe("openPopout", () => { beforeEach(() => { - jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({ - os: "linux", - arch: "x86-64", - nacl_arch: "x86-64", - }); jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ id: 1, left: 100, @@ -155,8 +150,6 @@ describe("BrowserPopupUtils", () => { width: PopupWidthOptions.default, }); jest.spyOn(BrowserApi, "createWindow").mockImplementation(); - jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation(); - jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation(); }); it("creates a window with the default window options", async () => { @@ -274,63 +267,6 @@ describe("BrowserPopupUtils", () => { url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`, }); }); - - it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => { - const url = "popup/index.html"; - jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); - jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ - os: "mac", - arch: "x86-64", - nacl_arch: "x86-64", - }); - jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({ - id: 1, - left: 100, - top: 100, - focused: false, - alwaysOnTop: false, - incognito: false, - width: PopupWidthOptions.default, - state: "fullscreen", - }); - jest - .spyOn(BrowserApi, "createWindow") - .mockResolvedValueOnce({ id: 2 } as chrome.windows.Window); - - await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 }); - expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, { - state: "maximized", - }); - expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, { - focused: true, - }); - }); - - it("doesnt exit fullscreen if the platform is not mac", async () => { - const url = "popup/index.html"; - jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false); - jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({ - os: "win", - arch: "x86-64", - nacl_arch: "x86-64", - }); - jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ - id: 1, - left: 100, - top: 100, - focused: false, - alwaysOnTop: false, - incognito: false, - width: PopupWidthOptions.default, - state: "fullscreen", - }); - - await BrowserPopupUtils.openPopout(url); - - expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, { - state: "maximized", - }); - }); }); describe("openCurrentPagePopout", () => { diff --git a/apps/browser/src/platform/browser/browser-popup-utils.ts b/apps/browser/src/platform/browser/browser-popup-utils.ts index c8dba57e708..7333023d178 100644 --- a/apps/browser/src/platform/browser/browser-popup-utils.ts +++ b/apps/browser/src/platform/browser/browser-popup-utils.ts @@ -168,29 +168,8 @@ export default class BrowserPopupUtils { ) { return; } - const platform = await BrowserApi.getPlatformInfo(); - const isMacOS = platform.os === "mac"; - const isFullscreen = senderWindow.state === "fullscreen"; - const isFullscreenAndMacOS = isFullscreen && isMacOS; - //macOS specific handling for improved UX when sender in fullscreen aka green button; - if (isFullscreenAndMacOS) { - await BrowserApi.updateWindowProperties(senderWindow.id, { - state: "maximized", - }); - //wait for macOS animation to finish - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - - const newWindow = await BrowserApi.createWindow(popoutWindowOptions); - - if (isFullscreenAndMacOS) { - await BrowserApi.updateWindowProperties(newWindow.id, { - focused: true, - }); - } - - return newWindow; + return await BrowserApi.createWindow(popoutWindowOptions); } /** From 3a232c92963a5c2216e4de2f5f12ff4aa1dff4e6 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:16:06 -0500 Subject: [PATCH 066/130] [PM-31348] phish cleanup - Address code review feedback from PR #18561 (Cursor-based phishing URL search) (#18638) --- .../phishing-detection/phishing-resources.ts | 4 -- .../services/phishing-data.service.spec.ts | 64 +++++++++++++++++- .../services/phishing-data.service.ts | 54 ++++----------- .../services/phishing-detection.service.ts | 66 ++++++------------- 4 files changed, 94 insertions(+), 94 deletions(-) diff --git a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts index 6595104207a..88068987dd7 100644 --- a/apps/browser/src/dirt/phishing-detection/phishing-resources.ts +++ b/apps/browser/src/dirt/phishing-detection/phishing-resources.ts @@ -7,8 +7,6 @@ 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({ @@ -58,8 +56,6 @@ 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 2d6c7a5a651..0cbb765ce0e 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 @@ -186,12 +186,74 @@ describe("PhishingDataService", () => { expect(result).toBe(false); expect(logService.error).toHaveBeenCalledWith( - "[PhishingDataService] IndexedDB lookup via hasUrl failed", + "[PhishingDataService] IndexedDB lookup failed", expect.any(Error), ); // Custom matcher is disabled, so no custom matcher error is expected expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled(); }); + + it("should use cursor-based search when useCustomMatcher is enabled", async () => { + // Temporarily enable custom matcher for this test + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + // Mock hasUrl to return false (no direct match) + mockIndexedDbService.hasUrl.mockResolvedValue(false); + // Mock findMatchingUrl to return true (custom matcher finds it) + mockIndexedDbService.findMatchingUrl.mockResolvedValue(true); + + const url = new URL("http://phish.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(true); + expect(mockIndexedDbService.hasUrl).toHaveBeenCalled(); + expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled(); + } finally { + // Restore original value + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); + + it("should return false when custom matcher finds no match (when enabled)", async () => { + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockResolvedValue(false); + + const url = new URL("http://safe.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled(); + } finally { + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); + + it("should handle custom matcher errors gracefully (when enabled)", async () => { + const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER; + (PhishingDataService as any).USE_CUSTOM_MATCHER = true; + + try { + mockIndexedDbService.hasUrl.mockResolvedValue(false); + mockIndexedDbService.findMatchingUrl.mockRejectedValue(new Error("Cursor error")); + + const url = new URL("http://error.com/path"); + const result = await service.isPhishingWebAddress(url); + + expect(result).toBe(false); + expect(logService.error).toHaveBeenCalledWith( + "[PhishingDataService] Custom matcher failed", + expect.any(Error), + ); + } finally { + (PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue; + } + }); }); describe("data updates", () => { 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 c34a94ecced..03759ba14bc 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 @@ -78,6 +78,10 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition( /** Coordinates fetching, caching, and patching of known phishing web addresses */ export class PhishingDataService { + // Cursor-based search is disabled due to performance (6+ minutes on large databases) + // Enable when performance is optimized via indexing or other improvements + private static readonly USE_CUSTOM_MATCHER = false; + // While background scripts do not necessarily need destroying, // processes in PhishingDataService are memory intensive. // We are adding the destroy to guard against accidental leaks. @@ -153,12 +157,8 @@ 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; } @@ -176,69 +176,37 @@ export class PhishingDataService { 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, - ); + this.logService.info("[PhishingDataService] Found phishing URL: " + urlHref); return true; } } catch (err) { - this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err); + this.logService.error("[PhishingDataService] IndexedDB lookup failed", err); } - // 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) { + // Custom matcher is disabled for performance (see USE_CUSTOM_MATCHER) + if (resource && resource.match && PhishingDataService.USE_CUSTOM_MATCHER) { try { - 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, - ); + this.logService.info("[PhishingDataService] Found phishing URL via matcher: " + 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, - ); + this.logService.error("[PhishingDataService] Custom matcher failed", err); 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 6ca5bad8942..2fa7bf8ec9e 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,14 +1,4 @@ -import { - distinctUntilChanged, - EMPTY, - filter, - map, - merge, - mergeMap, - Subject, - switchMap, - tap, -} from "rxjs"; +import { distinctUntilChanged, EMPTY, filter, map, merge, Subject, switchMap, tap } from "rxjs"; import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -43,7 +33,6 @@ 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, @@ -64,7 +53,7 @@ export class PhishingDetectionService { tap((message) => logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`), ), - mergeMap(async (message) => { + switchMap(async (message) => { const url = new URL(message.url); this._ignoredHostnames.add(url.hostname); await BrowserApi.navigateTabToUrl(message.tabId, url); @@ -89,40 +78,25 @@ export class PhishingDetectionService { prev.ignored === curr.ignored, ), tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)), - // 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)`, - ); - 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)`, - ); + // Use switchMap to cancel any in-progress check when navigating to a new URL + // This prevents race conditions where a stale check redirects the user incorrectly + switchMap(async ({ tabId, url, ignored }) => { + if (ignored) { + // The next time this host is visited, block again + this._ignoredHostnames.delete(url.hostname); + return; } - }, 5), + 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); + }), ); const onCancelCommand$ = messageListener From 1dfd68bf5702b0f977caf39d832fdc8ed6585d45 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:18:03 -0500 Subject: [PATCH 067/130] [deps] Autofill: Update concurrently to v9.2.1 (#17540) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 31 ++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index bf0c5196364..59bd89afce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -127,7 +127,7 @@ "base64-loader": "1.0.0", "browserslist": "4.28.1", "chromatic": "13.3.4", - "concurrently": "9.2.0", + "concurrently": "9.2.1", "copy-webpack-plugin": "13.0.1", "cross-env": "10.1.0", "css-loader": "7.1.2", @@ -20558,19 +20558,18 @@ } }, "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", @@ -20583,6 +20582,16 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/concurrently/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", diff --git a/package.json b/package.json index 3fabb6af099..1cc4cabbceb 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "base64-loader": "1.0.0", "browserslist": "4.28.1", "chromatic": "13.3.4", - "concurrently": "9.2.0", + "concurrently": "9.2.1", "copy-webpack-plugin": "13.0.1", "cross-env": "10.1.0", "css-loader": "7.1.2", From 9d8f1af62bf5986b39dc3b6425fcd0b4df6246f6 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 28 Jan 2026 15:19:39 -0600 Subject: [PATCH 068/130] PM-30539 created new component and added a filter (#18630) --- apps/web/src/locales/en/messages.json | 21 ++ .../applications.component.html | 128 ++++++++++ .../applications.component.ts | 221 ++++++++++++++++++ .../risk-insights.component.html | 5 + .../risk-insights.component.ts | 10 + libs/common/src/enums/feature-flag.enum.ts | 2 + 6 files changed, 387 insertions(+) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ecb5f8d2dfc..872509a81c2 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical":{ + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html new file mode 100644 index 00000000000..092cc4b73d8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -0,0 +1,128 @@ +@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) { + +} @else { + @let drawerDetails = dataService.drawerDetails$ | async; +
    +

    {{ "allApplications" | i18n }}

    +
    +
    +
    + {{ + "atRiskMembers" | i18n + }} +
    + {{ applicationSummary().totalAtRiskMemberCount }} + {{ + "cardMetrics" | i18n: applicationSummary().totalMemberCount + }} +
    +
    +

    + +

    +
    +
    +
    +
    +
    + {{ "atRiskApplications" | i18n }} +
    + {{ applicationSummary().totalAtRiskApplicationCount }} + {{ + "cardMetrics" | i18n: applicationSummary().totalApplicationCount + }} +
    +
    +

    + +

    +
    +
    +
    +
    +
    + + + + + +
    + + +
    +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts new file mode 100644 index 00000000000..0a393b26974 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -0,0 +1,221 @@ +import { + Component, + DestroyRef, + inject, + OnInit, + ChangeDetectionStrategy, + signal, + computed, +} from "@angular/core"; +import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, debounceTime, startWith } from "rxjs"; + +import { Security } from "@bitwarden/assets/svg"; +import { RiskInsightsDataService } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { createNewSummaryData } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; +import { + OrganizationReportSummary, + ReportStatus, +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ButtonModule, + IconButtonModule, + LinkModule, + NoItemsModule, + SearchModule, + TableDataSource, + ToastService, + TypographyModule, + ChipSelectComponent, +} from "@bitwarden/components"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; + +import { + ApplicationTableDataSource, + AppTableRowScrollableComponent, +} from "../shared/app-table-row-scrollable.component"; +import { ReportLoadingComponent } from "../shared/report-loading.component"; + +export const ApplicationFilterOption = { + All: "all", + Critical: "critical", + NonCritical: "nonCritical", +} as const; + +export type ApplicationFilterOption = + (typeof ApplicationFilterOption)[keyof typeof ApplicationFilterOption]; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-applications", + templateUrl: "./applications.component.html", + imports: [ + ReportLoadingComponent, + HeaderModule, + LinkModule, + SearchModule, + PipesModule, + NoItemsModule, + SharedModule, + AppTableRowScrollableComponent, + IconButtonModule, + TypographyModule, + ButtonModule, + ReactiveFormsModule, + ChipSelectComponent, + ], +}) +export class ApplicationsComponent implements OnInit { + destroyRef = inject(DestroyRef); + + protected ReportStatusEnum = ReportStatus; + protected noItemsIcon = Security; + + // Standard properties + protected readonly dataSource = new TableDataSource(); + protected readonly searchControl = new FormControl("", { nonNullable: true }); + + // Template driven properties + protected readonly selectedUrls = signal(new Set()); + protected readonly markingAsCritical = signal(false); + protected readonly applicationSummary = signal(createNewSummaryData()); + protected readonly criticalApplicationsCount = signal(0); + protected readonly totalApplicationsCount = signal(0); + protected readonly nonCriticalApplicationsCount = computed(() => { + return this.totalApplicationsCount() - this.criticalApplicationsCount(); + }); + + // filter related properties + protected readonly selectedFilter = signal(ApplicationFilterOption.All); + protected selectedFilterObservable = toObservable(this.selectedFilter); + protected readonly ApplicationFilterOption = ApplicationFilterOption; + protected readonly filterOptions = computed(() => [ + { + label: this.i18nService.t("critical", this.criticalApplicationsCount()), + value: ApplicationFilterOption.Critical, + }, + { + label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()), + value: ApplicationFilterOption.NonCritical, + }, + ]); + + constructor( + protected i18nService: I18nService, + protected activatedRoute: ActivatedRoute, + protected toastService: ToastService, + protected dataService: RiskInsightsDataService, + ) {} + + async ngOnInit() { + this.dataService.enrichedReportData$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (report) => { + if (report != null) { + this.applicationSummary.set(report.summaryData); + + // Map the report data to include the iconCipher for each application + const tableDataWithIcon = report.reportData.map((app) => ({ + ...app, + iconCipher: + app.cipherIds.length > 0 + ? this.dataService.getCipherIcon(app.cipherIds[0]) + : undefined, + })); + this.dataSource.data = tableDataWithIcon; + this.totalApplicationsCount.set(report.reportData.length); + } else { + this.dataSource.data = []; + } + }, + error: () => { + this.dataSource.data = []; + }, + }); + + this.dataService.criticalReportResults$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (criticalReport) => { + if (criticalReport != null) { + this.criticalApplicationsCount.set(criticalReport.reportData.length); + } else { + this.criticalApplicationsCount.set(0); + } + }, + }); + + combineLatest([ + this.searchControl.valueChanges.pipe(startWith("")), + this.selectedFilterObservable, + ]) + .pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)) + .subscribe(([searchText, selectedFilter]) => { + let filterFunction = (app: ApplicationTableDataSource) => true; + + if (selectedFilter === ApplicationFilterOption.Critical) { + filterFunction = (app) => app.isMarkedAsCritical; + } else if (selectedFilter === ApplicationFilterOption.NonCritical) { + filterFunction = (app) => !app.isMarkedAsCritical; + } + + this.dataSource.filter = (app) => + filterFunction(app) && + app.applicationName.toLowerCase().includes(searchText.toLowerCase()); + }); + } + + setFilterApplicationsByStatus(value: ApplicationFilterOption) { + this.selectedFilter.set(value); + } + + isMarkedAsCriticalItem(applicationName: string) { + return this.selectedUrls().has(applicationName); + } + + markAppsAsCritical = async () => { + this.markingAsCritical.set(true); + const count = this.selectedUrls().size; + + this.dataService + .saveCriticalApplications(Array.from(this.selectedUrls())) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), + }); + this.selectedUrls.set(new Set()); + this.markingAsCritical.set(false); + }, + error: () => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("applicationsMarkedAsCriticalFail"), + }); + }, + }); + }; + + showAppAtRiskMembers = async (applicationName: string) => { + await this.dataService.setDrawerForAppAtRiskMembers(applicationName); + }; + + onCheckboxChange = (applicationName: string, event: Event) => { + const isChecked = (event.target as HTMLInputElement).checked; + this.selectedUrls.update((selectedUrls) => { + const nextSelected = new Set(selectedUrls); + if (isChecked) { + nextSelected.add(applicationName); + } else { + nextSelected.delete(applicationName); + } + return nextSelected; + }); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html index 2a783e6dcc2..1e58d334288 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.html @@ -81,6 +81,11 @@ + @if (milestone11Enabled) { + + + + } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts index b307c91d29f..657bdb87d4a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/risk-insights.component.ts @@ -21,6 +21,8 @@ import { ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -38,6 +40,7 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { AllActivityComponent } from "./activity/all-activity.component"; import { AllApplicationsComponent } from "./all-applications/all-applications.component"; +import { ApplicationsComponent } from "./all-applications/applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; @@ -53,6 +56,7 @@ type ProgressStep = ReportProgress | null; templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, + ApplicationsComponent, AsyncActionsModule, ButtonModule, CommonModule, @@ -77,6 +81,7 @@ type ProgressStep = ReportProgress | null; export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); protected ReportStatusEnum = ReportStatus; + protected milestone11Enabled: boolean = false; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllActivity; @@ -114,6 +119,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { protected dialogService: DialogService, private fileDownloadService: FileDownloadService, private logService: LogService, + private configService: ConfigService, ) { this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllActivity; @@ -121,6 +127,10 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.milestone11Enabled = await this.configService.getFeatureFlag( + FeatureFlag.Milestone11AppPageImprovements, + ); + this.route.paramMap .pipe( takeUntilDestroyed(this.destroyRef), diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 244bd80d1fa..ac5f3c10260 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -59,6 +59,7 @@ export enum FeatureFlag { EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike", EventManagementForHuntress = "event-management-for-huntress", PhishingDetection = "phishing-detection", + Milestone11AppPageImprovements = "pm-30538-dirt-milestone-11-app-page-improvements", /* Vault */ PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk", @@ -121,6 +122,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE, [FeatureFlag.EventManagementForHuntress]: FALSE, [FeatureFlag.PhishingDetection]: FALSE, + [FeatureFlag.Milestone11AppPageImprovements]: FALSE, /* Vault */ [FeatureFlag.CipherKeyEncryption]: FALSE, From 0740c037a66ee140506a1d252a34abf0ffc92239 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:31:48 -0700 Subject: [PATCH 069/130] [PM-30922] Client changes to encrypt send access email list (#18486) --- .../browser/src/background/main.background.ts | 2 + apps/cli/src/register-oss-programs.ts | 2 +- .../service-container/service-container.ts | 2 + .../send/commands/create.command.spec.ts | 386 +++++++++++++++++ .../src/tools/send/commands/create.command.ts | 21 +- .../tools/send/commands/edit.command.spec.ts | 400 ++++++++++++++++++ .../src/tools/send/commands/edit.command.ts | 29 +- .../src/tools/send/models/send.response.ts | 5 + apps/cli/src/tools/send/send.program.ts | 48 ++- .../src/services/jslib-services.module.ts | 2 + .../src/tools/send/models/data/send.data.ts | 5 +- .../src/tools/send/models/domain/send.spec.ts | 286 ++++++++++++- .../src/tools/send/models/domain/send.ts | 21 +- .../send/models/request/send.request.spec.ts | 192 +++++++++ .../tools/send/models/request/send.request.ts | 4 +- .../send/models/response/send.response.ts | 8 +- .../src/tools/send/models/view/send.view.ts | 3 +- .../tools/send/services/send-api.service.ts | 1 + .../tools/send/services/send.service.spec.ts | 260 +++++++++++- .../src/tools/send/services/send.service.ts | 67 ++- .../services/test-data/send-tests.data.ts | 7 + 21 files changed, 1685 insertions(+), 66 deletions(-) create mode 100644 apps/cli/src/tools/send/commands/create.command.spec.ts create mode 100644 apps/cli/src/tools/send/commands/edit.command.spec.ts create mode 100644 libs/common/src/tools/send/models/request/send.request.spec.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 660fcb97bcf..8d741039b31 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1031,6 +1031,8 @@ export default class MainBackground { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.sendApiService = new SendApiService( this.apiService, diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 71d7aaa0d52..f0b0475c808 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); - sendProgram.register(); + await sendProgram.register(); } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 7bb8da27040..3e78eb36577 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -608,6 +608,8 @@ export class ServiceContainer { this.keyGenerationService, this.sendStateProvider, this.encryptService, + this.cryptoFunctionService, + this.configService, ); this.cipherFileUploadService = new CipherFileUploadService( diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts new file mode 100644 index 00000000000..d3702689812 --- /dev/null +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -0,0 +1,386 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { UserId } from "@bitwarden/user-core"; + +import { SendCreateCommand } from "./create.command"; + +describe("SendCreateCommand", () => { + let command: SendCreateCommand; + + const sendService = mock(); + const environmentService = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + environmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + } as any); + + command = new SendCreateCommand( + sendService, + environmentService, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + type: SendType.Text, + }), + null, + undefined, + ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.any(Object), + null as any, + "testPassword123", + ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com", "another@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { + id: "send-id", + emails: "test@example.com,another@example.com", + authType: AuthType.Email, + } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com,another@example.com"); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com"], + password: "jsonPassword123", + }; + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should use CLI value when JSON has different value of same type", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: [] as string[], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is whitespace only", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: " ", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 91e579c26c1..ad4ff9c4e18 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -18,7 +19,6 @@ import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; import { SendTextResponse } from "../models/send-text.response"; import { SendResponse } from "../models/send.response"; - export class SendCreateCommand { constructor( private sendService: SendService, @@ -81,12 +81,24 @@ export class SendCreateCommand { const emails = req.emails ?? options.emails ?? undefined; const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount; - if (emails !== undefined && password !== undefined) { + const hasEmails = emails != null && emails.length > 0; + const hasPassword = password != null && password.trim().length > 0; + + if (hasEmails && hasPassword) { return Response.badRequest("--password and --emails are mutually exclusive."); } req.key = null; req.maxAccessCount = maxAccessCount; + req.emails = emails; + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } const hasPremium$ = this.accountService.activeAccount$.pipe( switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)), @@ -136,11 +148,6 @@ export class SendCreateCommand { const sendView = SendResponse.toView(req); const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - encSend.emails = emails && emails.join(","); - await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts new file mode 100644 index 00000000000..5bac63d3821 --- /dev/null +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -0,0 +1,400 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { UserId } from "@bitwarden/user-core"; + +import { Response } from "../../../models/response"; +import { SendResponse } from "../models/send.response"; + +import { SendEditCommand } from "./edit.command"; +import { SendGetCommand } from "./get.command"; + +describe("SendEditCommand", () => { + let command: SendEditCommand; + + const sendService = mock(); + const getCommand = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + const mockSendId = "send-123"; + const mockSendView = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + text: { text: "test content", hidden: false }, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + } as SendView; + + const mockSend = { + id: mockSendId, + type: SendType.Text, + decrypt: jest.fn().mockResolvedValue(mockSendView), + }; + + const encodeRequest = (data: any) => Buffer.from(JSON.stringify(data)).toString("base64"); + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + sendService.getFromState.mockResolvedValue(mockSend as any); + getCommand.run.mockResolvedValue(Response.success(new SendResponse(mockSendView)) as any); + + command = new SendEditCommand( + sendService, + getCommand, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com", "another@example.com"], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com"], + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should prioritize CLI value when JSON has different value of same type", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: [] as string[], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should handle send not found", async () => { + sendService.getFromState.mockResolvedValue(null); + + const requestData = { + id: "nonexistent-id", + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + }); + + it("should handle type mismatch", async () => { + const requestData = { + id: mockSendId, + type: SendType.File, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("Cannot change a Send's type"); + }); + }); + }); + + describe("validation", () => { + it("should return error when requestJson is empty", async () => { + // Set BW_SERVE to prevent readStdin call + process.env.BW_SERVE = "true"; + + const response = await command.run("", {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`requestJson` was not provided."); + + delete process.env.BW_SERVE; + }); + + it("should return error when id is not provided", async () => { + const requestData = { + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`itemid` was not provided."); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 2c6d41d66ac..0709a33b88f 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -7,6 +7,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; @@ -53,14 +54,30 @@ export class SendEditCommand { req.id = normalizedOptions.itemId || req.id; if (normalizedOptions.emails) { req.emails = normalizedOptions.emails; - req.password = undefined; - } else if (normalizedOptions.password) { - req.emails = undefined; + } + if (normalizedOptions.password) { req.password = normalizedOptions.password; - } else if (req.password && (typeof req.password !== "string" || req.password === "")) { + } + if (req.password && (typeof req.password !== "string" || req.password === "")) { req.password = undefined; } + // Infer authType based on emails/password (mutually exclusive) + const hasEmails = req.emails != null && req.emails.length > 0; + const hasPassword = req.password != null && req.password.trim() !== ""; + + if (hasEmails && hasPassword) { + return Response.badRequest("--password and --emails are mutually exclusive."); + } + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } + if (!req.id) { return Response.error("`itemid` was not provided."); } @@ -90,10 +107,6 @@ export class SendEditCommand { try { const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - await this.sendApiService.save([encSend, encFileData]); } catch (e) { return Response.error(e); diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index b7655226be0..c8182cbfaf8 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; @@ -54,6 +55,7 @@ export class SendResponse implements BaseResponse { view.emails = send.emails ?? []; view.disabled = send.disabled; view.hideEmail = send.hideEmail; + view.authType = send.authType; return view; } @@ -92,6 +94,7 @@ export class SendResponse implements BaseResponse { emails?: Array; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(o?: SendView, webVaultUrl?: string) { if (o == null) { @@ -116,8 +119,10 @@ export class SendResponse implements BaseResponse { this.deletionDate = o.deletionDate; this.expirationDate = o.expirationDate; this.passwordSet = o.password != null; + this.emails = o.emails ?? []; this.disabled = o.disabled; this.hideEmail = o.hideEmail; + this.authType = o.authType; if (o.type === SendType.Text && o.text != null) { this.text = new SendTextResponse(o.text); diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 869d77a379c..a84b6c15ead 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -6,6 +6,7 @@ import * as path from "path"; import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; @@ -31,13 +32,16 @@ import { parseEmail } from "./util"; const writeLn = CliUtils.writeLn; export class SendProgram extends BaseProgram { - register() { - program.addCommand(this.sendCommand()); + async register() { + const emailAuthEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + program.addCommand(this.sendCommand(emailAuthEnabled)); // receive is accessible both at `bw receive` and `bw send receive` program.addCommand(this.receiveCommand()); } - private sendCommand(): Command { + private sendCommand(emailAuthEnabled: boolean): Command { return new Command("send") .argument("", "The data to Send. Specify as a filepath with the --file option") .description( @@ -59,9 +63,7 @@ export class SendProgram extends BaseProgram { new Option( "--email ", "optional emails to access this Send. Can also be specified in JSON.", - ) - .argParser(parseEmail) - .hideHelp(), + ).argParser(parseEmail), ) .option("-a, --maxAccessCount ", "The amount of max possible accesses.") .option("--hidden", "Hide in web by default. Valid only if --file is not set.") @@ -78,11 +80,18 @@ export class SendProgram extends BaseProgram { .addCommand(this.templateCommand()) .addCommand(this.getCommand()) .addCommand(this.receiveCommand()) - .addCommand(this.createCommand()) - .addCommand(this.editCommand()) + .addCommand(this.createCommand(emailAuthEnabled)) + .addCommand(this.editCommand(emailAuthEnabled)) .addCommand(this.removePasswordCommand()) .addCommand(this.deleteCommand()) .action(async (data: string, options: OptionValues) => { + if (options.email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const encodedJson = this.makeSendJson(data, options); let response: Response; @@ -199,7 +208,7 @@ export class SendProgram extends BaseProgram { }); } - private createCommand(): Command { + private createCommand(emailAuthEnabled: any): Command { return new Command("create") .argument("[encodedJson]", "JSON object to upload. Can also be piped in through stdin.") .description("create a Send") @@ -215,6 +224,14 @@ export class SendProgram extends BaseProgram { .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { // subcommands inherit flags from their parent; they cannot override them const { fullObject = false, email = undefined, password = undefined } = args.parent.opts(); + + if (email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const mergedOptions = { ...options, fullObject: fullObject, @@ -227,7 +244,7 @@ export class SendProgram extends BaseProgram { }); } - private editCommand(): Command { + private editCommand(emailAuthEnabled: any): Command { return new Command("edit") .argument( "[encodedJson]", @@ -243,6 +260,14 @@ export class SendProgram extends BaseProgram { }) .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { await this.exitIfLocked(); + const { email = undefined, password = undefined } = args.parent.opts(); + if (email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const getCmd = new SendGetCommand( this.serviceContainer.sendService, this.serviceContainer.environmentService, @@ -259,8 +284,6 @@ export class SendProgram extends BaseProgram { this.serviceContainer.accountService, ); - // subcommands inherit flags from their parent; they cannot override them - const { email = undefined, password = undefined } = args.parent.opts(); const mergedOptions = { ...options, email, @@ -328,6 +351,7 @@ export class SendProgram extends BaseProgram { file: sendFile, text: sendText, type: type, + emails: options.email ?? undefined, }); return Buffer.from(JSON.stringify(template), "utf8").toString("base64"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1ecf7fe3e3d..5a582626e68 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -858,6 +858,8 @@ const safeProviders: SafeProvider[] = [ KeyGenerationService, SendStateProviderAbstraction, EncryptService, + CryptoFunctionServiceAbstraction, + ConfigService, ], }), safeProvider({ diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 7eeb15f3ebe..4081eba2878 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -11,7 +11,6 @@ export class SendData { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileData; @@ -24,8 +23,10 @@ export class SendData { deletionDate: string; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -46,8 +47,10 @@ export class SendData { this.deletionDate = response.deletionDate; this.password = response.password; this.emails = response.emails; + this.emailHashes = ""; this.disabled = response.disable; this.hideEmail = response.hideEmail; + this.authType = response.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index cd51390908e..f660333c917 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // 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 @@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; -import { Send } from "./send"; import { SendText } from "./send-text"; describe("Send", () => { @@ -26,7 +26,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -41,9 +40,11 @@ describe("Send", () => { expirationDate: "2022-01-31T12:00:00.000Z", deletionDate: "2022-01-31T12:00:00.000Z", password: "password", - emails: null!, + emails: "", + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -69,6 +70,8 @@ describe("Send", () => { expirationDate: null, deletionDate: null, password: undefined, + emails: null, + emailHashes: undefined, disabled: undefined, hideEmail: undefined, }); @@ -81,7 +84,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -95,9 +97,11 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", - emails: null!, + emails: null, + emailHashes: "", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -121,14 +125,22 @@ describe("Send", () => { send.expirationDate = new Date("2022-01-31T12:00:00.000Z"); send.deletionDate = new Date("2022-01-31T12:00:00.000Z"); send.password = "password"; + send.emails = null; send.disabled = false; send.hideEmail = true; + send.authType = AuthType.None; const encryptService = mock(); const keyService = mock(); encryptService.decryptBytes .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); + encryptService.decryptString + .calledWith(send.name, "cryptoKey" as any) + .mockResolvedValue("name"); + encryptService.decryptString + .calledWith(send.notes, "cryptoKey" as any) + .mockResolvedValue("notes"); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey)); @@ -137,12 +149,6 @@ describe("Send", () => { const view = await send.decrypt(userId); expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey"); - expect(send.name.decrypt).toHaveBeenNthCalledWith( - 1, - null, - "cryptoKey", - "Property: name; ObjectContext: No Domain Context", - ); expect(view).toMatchObject({ id: "id", @@ -150,7 +156,6 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, - authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), @@ -161,8 +166,265 @@ describe("Send", () => { expirationDate: new Date("2022-01-31T12:00:00.000Z"), deletionDate: new Date("2022-01-31T12:00:00.000Z"), password: "password", + emails: [], disabled: false, hideEmail: true, + authType: AuthType.None, + }); + }); + + describe("Email decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should decrypt and parse single email", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(encryptService.decryptString).toHaveBeenCalledWith(send.emails, "cryptoKey"); + expect(view.emails).toEqual(["test@example.com"]); + }); + + it("should decrypt and parse multiple emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("test@example.com,user@test.com,admin@domain.com"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve("test@example.com,user@test.com,admin@domain.com"); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com", "admin@domain.com"]); + }); + + it("should trim whitespace from decrypted emails", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(" test@example.com , user@test.com "); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(" test@example.com , user@test.com "); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual(["test@example.com", "user@test.com"]); + }); + + it("should return empty array when emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(expect.anything(), "cryptoKey"); + }); + + it("should return empty array when decrypted emails is empty string", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc(""); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(""); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + + it("should return empty array when decrypted emails is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = mockEnc("something"); + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.emails) { + return Promise.resolve(null); + } + if (encString === send.name) { + return Promise.resolve("name"); + } + if (encString === send.notes) { + return Promise.resolve("notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.emails).toEqual([]); + }); + }); + + describe("Null handling for name and notes decryption", () => { + let encryptService: jest.Mocked; + let keyService: jest.Mocked; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; + + beforeEach(() => { + encryptService = mock(); + keyService = mock(); + encryptService.decryptBytes.mockResolvedValue(makeStaticByteArray(32)); + keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); + keyService.userKey$.mockReturnValue(of(userKey)); + (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); + }); + + it("should return null for name when name is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = null; + send.notes = mockEnc("notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.name).toBeNull(); + expect(encryptService.decryptString).not.toHaveBeenCalledWith(null, expect.anything()); + }); + + it("should return null for notes when notes is null", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("name"); + send.notes = null; + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + const view = await send.decrypt(userId); + + expect(view.notes).toBeNull(); + }); + + it("should decrypt non-null name and notes", async () => { + const send = new Send(); + send.id = "id"; + send.type = SendType.Text; + send.name = mockEnc("Test Name"); + send.notes = mockEnc("Test Notes"); + send.key = mockEnc("key"); + send.emails = null; + send.text = mock(); + send.text.decrypt = jest.fn().mockResolvedValue("textView" as any); + + encryptService.decryptString.mockImplementation((encString, key) => { + if (encString === send.name) { + return Promise.resolve("Test Name"); + } + if (encString === send.notes) { + return Promise.resolve("Test Notes"); + } + return Promise.resolve(""); + }); + + const view = await send.decrypt(userId); + + expect(view.name).toBe("Test Name"); + expect(view.notes).toBe("Test Notes"); }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 82c37a17528..5247d35c655 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -20,7 +20,6 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; - authType: AuthType; name: EncString; notes: EncString; file: SendFile; @@ -32,9 +31,11 @@ export class Send extends Domain { expirationDate: Date; deletionDate: Date; password: string; - emails: string; + emails: EncString; + emailHashes: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(obj?: SendData) { super(); @@ -51,6 +52,7 @@ export class Send extends Domain { name: null, notes: null, key: null, + emails: null, }, ["id", "accessId"], ); @@ -60,12 +62,13 @@ export class Send extends Domain { this.maxAccessCount = obj.maxAccessCount; this.accessCount = obj.accessCount; this.password = obj.password; - this.emails = obj.emails; + this.emailHashes = obj.emailHashes; this.disabled = obj.disabled; this.revisionDate = obj.revisionDate != null ? new Date(obj.revisionDate) : null; this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null; this.hideEmail = obj.hideEmail; + this.authType = obj.authType; switch (this.type) { case SendType.Text: @@ -91,8 +94,17 @@ export class Send extends Domain { // model.key is a seed used to derive a key, not a SymmetricCryptoKey model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); model.cryptoKey = await keyService.makeSendKey(model.key); + model.name = + this.name != null ? await encryptService.decryptString(this.name, model.cryptoKey) : null; + model.notes = + this.notes != null ? await encryptService.decryptString(this.notes, model.cryptoKey) : null; - await this.decryptObj(this, model, ["name", "notes"], model.cryptoKey); + if (this.emails != null) { + const decryptedEmails = await encryptService.decryptString(this.emails, model.cryptoKey); + model.emails = decryptedEmails ? decryptedEmails.split(",").map((e) => e.trim()) : []; + } else { + model.emails = []; + } switch (this.type) { case SendType.File: @@ -121,6 +133,7 @@ export class Send extends Domain { key: EncString.fromJSON(obj.key), name: EncString.fromJSON(obj.name), notes: EncString.fromJSON(obj.notes), + emails: EncString.fromJSON(obj.emails), text: SendText.fromJSON(obj.text), file: SendFile.fromJSON(obj.file), revisionDate, diff --git a/libs/common/src/tools/send/models/request/send.request.spec.ts b/libs/common/src/tools/send/models/request/send.request.spec.ts new file mode 100644 index 00000000000..1daee1d01ff --- /dev/null +++ b/libs/common/src/tools/send/models/request/send.request.spec.ts @@ -0,0 +1,192 @@ +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; + +import { EncString } from "../../../../key-management/crypto/models/enc-string"; +import { SendType } from "../../types/send-type"; +import { SendText } from "../domain/send-text"; + +import { SendRequest } from "./send.request"; + +describe("SendRequest", () => { + describe("constructor", () => { + it("should populate emails with encrypted string from Send.emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBe("encryptedEmailList"); + }); + + it("should populate emailHashes from Send.emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("encryptedEmailList"); + send.emailHashes = "HASH1,HASH2,HASH3"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe("HASH1,HASH2,HASH3"); + }); + + it("should set emails to null when Send.emails is null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emails).toBeNull(); + expect(request.emailHashes).toBe(""); + }); + + it("should handle empty emailHashes", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.emailHashes).toBe(""); + }); + + it("should not expose plaintext emails", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encrypted|emaildata|here"); + send.emailHashes = "ABC123,DEF456"; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + // Ensure the request contains the encrypted string format, not plaintext + expect(request.emails).toBe("2.encrypted|emaildata|here"); + expect(request.emails).not.toContain("@"); + }); + + it("should handle name being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = null; + send.notes = new EncString("encryptedNotes"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.name).toBeNull(); + }); + + it("should handle notes being null", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.notes = null; + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send); + + expect(request.notes).toBeNull(); + }); + + it("should include fileLength when provided for text send", () => { + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = null; + send.emailHashes = ""; + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + const request = new SendRequest(send, 1024); + + expect(request.fileLength).toBe(1024); + }); + }); + + describe("Email auth requirements", () => { + it("should create request with encrypted emails and plaintext emailHashes", () => { + // Setup: A Send with encrypted emails and computed hashes + const send = new Send(); + send.type = SendType.Text; + send.name = new EncString("encryptedName"); + send.key = new EncString("encryptedKey"); + send.emails = new EncString("2.encryptedEmailString|data"); + send.emailHashes = "A1B2C3D4,E5F6G7H8"; // Plaintext hashes + send.disabled = false; + send.hideEmail = false; + send.text = new SendText(); + send.text.text = new EncString("text"); + send.text.hidden = false; + + // Act: Create the request + const request = new SendRequest(send); + + // emails field contains encrypted value + expect(request.emails).toBe("2.encryptedEmailString|data"); + expect(request.emails).toContain("encrypted"); + + //emailHashes field contains plaintext comma-separated hashes + expect(request.emailHashes).toBe("A1B2C3D4,E5F6G7H8"); + expect(request.emailHashes).not.toContain("encrypted"); + expect(request.emailHashes.split(",")).toHaveLength(2); + }); + }); +}); diff --git a/libs/common/src/tools/send/models/request/send.request.ts b/libs/common/src/tools/send/models/request/send.request.ts index 902ca0a2c54..37590e40108 100644 --- a/libs/common/src/tools/send/models/request/send.request.ts +++ b/libs/common/src/tools/send/models/request/send.request.ts @@ -18,6 +18,7 @@ export class SendRequest { file: SendFileApi; password: string; emails: string; + emailHashes: string; disabled: boolean; hideEmail: boolean; @@ -31,7 +32,8 @@ export class SendRequest { this.deletionDate = send.deletionDate != null ? send.deletionDate.toISOString() : null; this.key = send.key != null ? send.key.encryptedString : null; this.password = send.password; - this.emails = send.emails; + this.emails = send.emails ? send.emails.encryptedString : null; + this.emailHashes = send.emailHashes; this.disabled = send.disabled; this.hideEmail = send.hideEmail; diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 7a7885d5ae1..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; + import { BaseResponse } from "../../../../models/response/base.response"; -import { AuthType } from "../../types/auth-type"; -import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse { emails: string; disable: boolean; hideEmail: boolean; + authType: AuthType; constructor(response: any) { super(response); @@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse { this.emails = this.getResponseProperty("Emails"); this.disable = this.getResponseProperty("Disabled") || false; this.hideEmail = this.getResponseProperty("HideEmail") || false; + this.authType = this.getResponseProperty("AuthType"); const text = this.getResponseProperty("Text"); if (text != null) { diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index d07de6d8293..150a649671b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -19,7 +19,6 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; - authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; @@ -31,6 +30,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; + authType: AuthType = null; constructor(s?: Send) { if (!s) { @@ -49,6 +49,7 @@ export class SendView implements View { this.disabled = s.disabled; this.password = s.password; this.hideEmail = s.hideEmail; + this.authType = s.authType; } get urlB64Key(): string { diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index f09117316d8..57004b6ff0e 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -189,6 +189,7 @@ export class SendApiService implements SendApiServiceAbstraction { private async upload(sendData: [Send, EncArrayBuffer]): Promise { const request = new SendRequest(sendData[0], sendData[1]?.buffer.byteLength); + let response: SendResponse; if (sendData[0].id == null) { if (sendData[0].type === SendType.Text) { diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index fb99ddbe3bc..1c587327098 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; // 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 { KeyService } from "@bitwarden/key-management"; @@ -16,6 +17,7 @@ import { import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { EnvironmentService } from "../../../platform/abstractions/environment.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; @@ -29,6 +31,7 @@ import { SendTextApi } from "../models/api/send-text.api"; import { SendFileData } from "../models/data/send-file.data"; import { SendTextData } from "../models/data/send-text.data"; import { SendData } from "../models/data/send.data"; +import { SendTextView } from "../models/view/send-text.view"; import { SendView } from "../models/view/send.view"; import { SendType } from "../types/send-type"; @@ -48,7 +51,8 @@ describe("SendService", () => { const keyGenerationService = mock(); const encryptService = mock(); const environmentService = mock(); - + const cryptoFunctionService = mock(); + const configService = mock(); let sendStateProvider: SendStateProvider; let sendService: SendService; @@ -94,6 +98,8 @@ describe("SendService", () => { keyGenerationService, sendStateProvider, encryptService, + cryptoFunctionService, + configService, ); }); @@ -573,4 +579,256 @@ describe("SendService", () => { expect(sendsAfterDelete.length).toBe(0); }); }); + + describe("encrypt", () => { + let sendView: SendView; + const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const mockCryptoKey = new SymmetricCryptoKey(new Uint8Array(32)); + + beforeEach(() => { + sendView = new SendView(); + sendView.id = "sendId"; + sendView.type = SendType.Text; + sendView.name = "Test Send"; + sendView.notes = "Test Notes"; + const sendTextView = new SendTextView(); + sendTextView.text = "test text"; + sendTextView.hidden = false; + sendView.text = sendTextView; + sendView.key = new Uint8Array(16); + sendView.cryptoKey = mockCryptoKey; + sendView.maxAccessCount = 5; + sendView.disabled = false; + sendView.hideEmail = false; + sendView.deletionDate = new Date("2024-12-31"); + sendView.expirationDate = null; + + keyService.userKey$.mockReturnValue(of(userKey)); + keyService.makeSendKey.mockResolvedValue(mockCryptoKey); + encryptService.encryptBytes.mockResolvedValue({ encryptedString: "encryptedKey" } as any); + encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any); + }); + + describe("when SendEmailOTP feature flag is ON", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + cryptoFunctionService.hash.mockClear(); + }); + + describe("email encryption", () => { + it("should encrypt emails when email list is provided", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + cryptoFunctionService.hash.mockResolvedValue(new Uint8Array([0xab, 0xcd])); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith( + "test@example.com,user@test.com", + mockCryptoKey, + ); + expect(send.emails).toEqual({ encryptedString: "encrypted" }); + expect(send.password).toBeNull(); + }); + + it("should set emails to null when email list is empty", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is null", async () => { + sendView.emails = null; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + + it("should set emails to null when email list is undefined", async () => { + sendView.emails = undefined; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + }); + }); + + describe("email hashing", () => { + it("should hash emails using SHA-256 and return uppercase hex", async () => { + sendView.emails = ["test@example.com"]; + const mockHash = new Uint8Array([0xab, 0xcd, 0xef]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(send.emailHashes).toBe("ABCDEF"); + }); + + it("should hash multiple emails and return comma-separated hashes", async () => { + sendView.emails = ["test@example.com", "user@test.com"]; + const mockHash1 = new Uint8Array([0xab, 0xcd]); + const mockHash2 = new Uint8Array([0x12, 0x34]); + + cryptoFunctionService.hash + .mockResolvedValueOnce(mockHash1) + .mockResolvedValueOnce(mockHash2); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + expect(send.emailHashes).toBe("ABCD,1234"); + }); + + it("should trim and lowercase emails before hashing", async () => { + sendView.emails = [" Test@Example.COM ", "USER@test.com"]; + const mockHash = new Uint8Array([0xff]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + await sendService.encrypt(sendView, null, null); + + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("test@example.com", "sha256"); + expect(cryptoFunctionService.hash).toHaveBeenCalledWith("user@test.com", "sha256"); + }); + + it("should set emailHashes to empty string when no emails", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should handle single email correctly", async () => { + sendView.emails = ["single@test.com"]; + const mockHash = new Uint8Array([0xa1, 0xb2, 0xc3]); + + cryptoFunctionService.hash.mockResolvedValue(mockHash); + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emailHashes).toBe("A1B2C3"); + }); + }); + + describe("emails and password mutual exclusivity", () => { + it("should set password to null when emails are provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeDefined(); + expect(send.password).toBeNull(); + }); + + it("should set password when no emails are provided", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.password).toBe("hashedPassword"); + }); + }); + }); + + describe("when SendEmailOTP feature flag is OFF", () => { + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + cryptoFunctionService.hash.mockClear(); + }); + + it("should NOT encrypt emails even when provided", async () => { + sendView.emails = ["test@example.com"]; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should use password when provided and flag is OFF", async () => { + sendView.emails = []; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + }); + + it("should ignore emails and use password when both provided", async () => { + sendView.emails = ["test@example.com"]; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue({ + keyB64: "hashedPassword", + } as any); + + const [send] = await sendService.encrypt(sendView, null, "password123"); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBe("hashedPassword"); + expect(cryptoFunctionService.hash).not.toHaveBeenCalled(); + }); + + it("should set emails and password to null when neither provided", async () => { + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.emails).toBeNull(); + expect(send.emailHashes).toBe(""); + expect(send.password).toBeUndefined(); + }); + }); + + describe("null handling for name and notes", () => { + it("should handle null name correctly", async () => { + sendView.name = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.name).toBeNull(); + }); + + it("should handle null notes correctly", async () => { + sendView.notes = null; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(send.notes).toBeNull(); + }); + + it("should encrypt non-null name and notes", async () => { + sendView.name = "Test Name"; + sendView.notes = "Test Notes"; + sendView.emails = []; + + const [send] = await sendService.encrypt(sendView, null, null); + + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Name", mockCryptoKey); + expect(encryptService.encryptString).toHaveBeenCalledWith("Test Notes", mockCryptoKey); + expect(send.name).toEqual({ encryptedString: "encrypted" }); + expect(send.notes).toEqual({ encryptedString: "encrypted" }); + }); + }); + }); }); diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index c274d90146e..078e94b2563 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -7,9 +7,12 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; import { KeyGenerationService } from "../../../key-management/crypto"; +import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { Utils } from "../../../platform/misc/utils"; import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; @@ -51,6 +54,8 @@ export class SendService implements InternalSendServiceAbstraction { private keyGenerationService: KeyGenerationService, private stateProvider: SendStateProvider, private encryptService: EncryptService, + private cryptoFunctionService: CryptoFunctionService, + private configService: ConfigService, ) {} async encrypt( @@ -80,19 +85,30 @@ export class SendService implements InternalSendServiceAbstraction { model.cryptoKey = key.derivedKey; } + // Check feature flag for email OTP authentication + const sendEmailOTPEnabled = await this.configService.getFeatureFlag(FeatureFlag.SendEmailOTP); + const hasEmails = (model.emails?.length ?? 0) > 0; - if (hasEmails) { - send.emails = model.emails.join(","); + + if (sendEmailOTPEnabled && hasEmails) { + const plaintextEmails = model.emails.join(","); + send.emails = await this.encryptService.encryptString(plaintextEmails, model.cryptoKey); + send.emailHashes = await this.hashEmails(plaintextEmails); send.password = null; - } else if (password != null) { - // Note: Despite being called key, the passwordKey is not used for encryption. - // It is used as a static proof that the client knows the password, and has the encryption key. - const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( - password, - model.key, - new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), - ); - send.password = passwordKey.keyB64; + } else { + send.emails = null; + send.emailHashes = ""; + + if (password != null) { + // Note: Despite being called key, the passwordKey is not used for encryption. + // It is used as a static proof that the client knows the password, and has the encryption key. + const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( + password, + model.key, + new PBKDF2KdfConfig(SEND_KDF_ITERATIONS), + ); + send.password = passwordKey.keyB64; + } } const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userKey == null) { @@ -100,10 +116,14 @@ export class SendService implements InternalSendServiceAbstraction { } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); - // FIXME: model.name can be null. encryptString should not be called with null values. - send.name = await this.encryptService.encryptString(model.name, model.cryptoKey); - // FIXME: model.notes can be null. encryptString should not be called with null values. - send.notes = await this.encryptService.encryptString(model.notes, model.cryptoKey); + send.name = + model.name != null + ? await this.encryptService.encryptString(model.name, model.cryptoKey) + : null; + send.notes = + model.notes != null + ? await this.encryptService.encryptString(model.notes, model.cryptoKey) + : null; if (send.type === SendType.Text) { send.text = new SendText(); // FIXME: model.text.text can be null. encryptString should not be called with null values. @@ -127,6 +147,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; } @@ -371,4 +393,19 @@ export class SendService implements InternalSendServiceAbstraction { decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name")); return decryptedSends; } + + private async hashEmails(emails: string): Promise { + if (!emails) { + return ""; + } + + const emailArray = emails.split(",").map((e) => e.trim().toLowerCase()); + const hashPromises = emailArray.map(async (email) => { + const hash: Uint8Array = await this.cryptoFunctionService.hash(email, "sha256"); + return Utils.fromBufferToHex(hash).toUpperCase(); + }); + + const hashes = await Promise.all(hashPromises); + return hashes.join(","); + } } diff --git a/libs/common/src/tools/send/services/test-data/send-tests.data.ts b/libs/common/src/tools/send/services/test-data/send-tests.data.ts index c1d04ab2926..9c4e121edc0 100644 --- a/libs/common/src/tools/send/services/test-data/send-tests.data.ts +++ b/libs/common/src/tools/send/services/test-data/send-tests.data.ts @@ -20,6 +20,7 @@ export function testSendViewData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = []; return data; } @@ -39,6 +40,8 @@ export function createSendData(value: Partial = {}) { expirationDate: "2024-09-04", deletionDate: "2024-09-04", password: "password", + emails: "", + emailHashes: "", disabled: false, hideEmail: false, }; @@ -62,6 +65,8 @@ export function testSendData(id: string, name: string) { data.deletionDate = null; data.notes = "Notes!!"; data.key = null; + data.emails = ""; + data.emailHashes = ""; return data; } @@ -77,5 +82,7 @@ export function testSend(id: string, name: string) { data.deletionDate = null; data.notes = new EncString("Notes!!"); data.key = null; + data.emails = null; + data.emailHashes = ""; return data; } From 5b0cccc0fa31c8c24331e409933d062e519f59eb Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:05:17 -0800 Subject: [PATCH 070/130] [PM-29952] Fix: Access Intelligence password change tasks progress bar (#18488) * check tasks completed after report generation * fix type safety --- .../password-change-metric.component.ts | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 60b53f7405d..c1a00731100 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - Injector, OnInit, Signal, computed, @@ -11,7 +10,7 @@ import { input, signal, } from "@angular/core"; -import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; +import { toSignal } from "@angular/core/rxjs-interop"; import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -59,6 +58,9 @@ export class PasswordChangeMetricComponent implements OnInit { private readonly _tasks: Signal = signal([]); private readonly _atRiskCipherIds: Signal = signal([]); private readonly _hasCriticalApplications: Signal = signal(false); + private readonly _reportGeneratedAt: Signal = signal( + undefined, + ); // Computed properties readonly tasksCount = computed(() => this._tasks().length); @@ -80,8 +82,24 @@ export class PasswordChangeMetricComponent implements OnInit { } const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); - const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); - const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); + const inProgressTaskIds = new Set(inProgressTasks.map((task) => task.cipherId)); + + const reportGeneratedAt = this._reportGeneratedAt(); + const completedTasksAfterReportGeneration = reportGeneratedAt + ? tasks.filter( + (task) => + task.status === SecurityTaskStatus.Completed && + new Date(task.revisionDate) >= reportGeneratedAt, + ) + : []; + const completedTaskIds = new Set( + completedTasksAfterReportGeneration.map((task) => task.cipherId), + ); + + // find cipher ids from last report that do not have a corresponding in progress task (awaiting password reset) OR completed task + const unassignedIds = atRiskIds.filter( + (id) => !inProgressTaskIds.has(id) && !completedTaskIds.has(id), + ); return unassignedIds.length; }); @@ -109,36 +127,26 @@ export class PasswordChangeMetricComponent implements OnInit { constructor( private allActivitiesService: AllActivitiesService, private i18nService: I18nService, - private injector: Injector, private riskInsightsDataService: RiskInsightsDataService, protected securityTasksService: AccessIntelligenceSecurityTasksService, private toastService: ToastService, ) { - // Setup the _tasks signal by manually passing in the injector - this._tasks = toSignal(this.securityTasksService.tasks$, { - initialValue: [], - injector: this.injector, - }); - // Setup the _atRiskCipherIds signal by manually passing in the injector + this._tasks = toSignal(this.securityTasksService.tasks$, { initialValue: [] }); this._atRiskCipherIds = toSignal( this.riskInsightsDataService.criticalApplicationAtRiskCipherIds$, - { - initialValue: [], - injector: this.injector, - }, + { initialValue: [] }, ); - this._hasCriticalApplications = toSignal( this.riskInsightsDataService.criticalReportResults$.pipe( - takeUntilDestroyed(this.destroyRef), map((report) => { return report != null && (report.reportData?.length ?? 0) > 0; }), ), - { - initialValue: false, - injector: this.injector, - }, + { initialValue: false }, + ); + this._reportGeneratedAt = toSignal( + this.riskInsightsDataService.enrichedReportData$.pipe(map((report) => report?.creationDate)), + { initialValue: undefined }, ); effect(() => { From 2109092a9443d178e3c1be2a74819b85a390c0d4 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:20:17 -0800 Subject: [PATCH 071/130] [PM-31354] Fix Reports page loading (#18631) * fix reports page loading * update to signals, leave OnPush detection strategy --- .../dirt/reports/pages/reports-home.component.html | 2 +- .../app/dirt/reports/pages/reports-home.component.ts | 8 ++++---- .../shared/report-list/report-list.component.html | 2 +- .../shared/report-list/report-list.component.ts | 11 +++-------- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/web/src/app/dirt/reports/pages/reports-home.component.html b/apps/web/src/app/dirt/reports/pages/reports-home.component.html index 9101933bc40..ee3caae4212 100644 --- a/apps/web/src/app/dirt/reports/pages/reports-home.component.html +++ b/apps/web/src/app/dirt/reports/pages/reports-home.component.html @@ -3,5 +3,5 @@

    {{ "reportsDesc" | i18n }}

    - +
    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 25cf663ba7e..5dd7f1d3ec0 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 { ChangeDetectionStrategy, Component, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, OnInit, signal } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,7 +16,7 @@ import { ReportEntry, ReportVariant } from "../shared"; standalone: false, }) export class ReportsHomeComponent implements OnInit { - reports: ReportEntry[]; + readonly reports = signal([]); constructor( private billingAccountProfileStateService: BillingAccountProfileStateService, @@ -32,7 +32,7 @@ export class ReportsHomeComponent implements OnInit { ? ReportVariant.Enabled : ReportVariant.RequiresPremium; - this.reports = [ + this.reports.set([ { ...reports[ReportType.ExposedPasswords], variant: reportRequiresPremium, @@ -57,6 +57,6 @@ export class ReportsHomeComponent implements OnInit { ...reports[ReportType.DataBreach], variant: ReportVariant.Enabled, }, - ]; + ]); } } diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html index bba57882027..4726eb5c42f 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.component.html @@ -1,7 +1,7 @@
    - @for (report of reports; track report) { + @for (report of reports(); track report) {
    ([]); } From 1db00097d21d66ff03015db12eea0cd33e84d387 Mon Sep 17 00:00:00 2001 From: Leslie Xiong Date: Thu, 29 Jan 2026 04:51:46 -0500 Subject: [PATCH 072/130] fixed nested folders missing 'onEditFolder' (#18629) --- .../vault-filter/filters/folder-filter.component.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html index f063167c48f..0a0193302a0 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/folder-filter.component.html @@ -22,7 +22,11 @@ > } @for (childFolder of folder().children; track childFolder.node.id) { - + } } @else { From 6d1693050c252966fbc04e0535e036d358d0e908 Mon Sep 17 00:00:00 2001 From: Colton Hurst Date: Thu, 29 Jan 2026 08:39:45 -0500 Subject: [PATCH 073/130] Autofill Provider Readme Update (#18624) * Update the autofill provider readme * Update casing based on pr suggestion --- apps/desktop/desktop_native/autofill_provider/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/desktop_native/autofill_provider/README.md b/apps/desktop/desktop_native/autofill_provider/README.md index 86c49356161..7f1312a0700 100644 --- a/apps/desktop/desktop_native/autofill_provider/README.md +++ b/apps/desktop/desktop_native/autofill_provider/README.md @@ -2,11 +2,14 @@ A library for native autofill providers to interact with a host Bitwarden desktop app. +In this desktop context, "native autofill providers" are operating system frameworks or APIs (like the macOS [autofill framework](https://developer.apple.com/documentation/Security/password-autofill)) that allow Bitwarden to provide provide user credentials for things like autofill, passkey operations, etc. + # Explainer: Mac OS Native Passkey Provider This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context. ## The high level + MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys). We’ve written a Swift-based native autofill-extension. It’s bundled in the app-bundle in PlugIns, similar to the safari-extension. @@ -16,7 +19,7 @@ This swift extension currently communicates with our Electron app through IPC ba Footnotes: * We're not using the IPC framework as the implementation pre-dates the IPC framework. -* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed. +* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed. Electron receives the messages and passes it to Angular (through the electron-renderer event system). @@ -26,7 +29,7 @@ Our existing fido2 services in the renderer respond to events, displaying UI as We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ‘ui environments' in mind. -Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors. +Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors. We’ve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app. From 29523b6e745bba91dd22b854a68cd3aeccee80b0 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 29 Jan 2026 15:03:00 +0100 Subject: [PATCH 074/130] [PM-31012] Improve loading time for lock component (#18450) * Improve loading time for lock component * Reset interval to 1000 * Remove interval import --- libs/key-management-ui/src/lock/components/lock.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6057fe06456..9900aa6e827 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, filter, firstValueFrom, - interval, + timer, mergeMap, Subject, switchMap, @@ -199,7 +199,7 @@ export class LockComponent implements OnInit, OnDestroy { } private listenForUnlockOptionsChanges() { - interval(1000) + timer(0, 1000) .pipe( mergeMap(async () => { if (this.activeAccount?.id != null) { From 3dcee2ef5de12dfcb092e52887c72d7e8c5f441c Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Thu, 29 Jan 2026 10:08:14 -0500 Subject: [PATCH 075/130] Fix DDG build action file list (#18390) * Fix file list * Add ddg-alert-files-list branch to test PR triggers * Update branches for pull request trigger Restrict pull request monitoring to the main branch only. --- .github/workflows/alert-ddg-files-modified.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/alert-ddg-files-modified.yml b/.github/workflows/alert-ddg-files-modified.yml index 35eb0515c10..49918563644 100644 --- a/.github/workflows/alert-ddg-files-modified.yml +++ b/.github/workflows/alert-ddg-files-modified.yml @@ -73,7 +73,7 @@ jobs: _MONITORED_FILES: ${{ steps.changed-files.outputs.monitored_files }} with: script: | - const changedFiles = `$_MONITORED_FILES`.split(' ').filter(file => file.trim() !== ''); + const changedFiles = process.env._MONITORED_FILES.split(' ').filter(file => file.trim() !== ''); const message = ` ⚠️🦆 **DuckDuckGo Integration files have been modified in this PR:** From 96ce13760b86348f147133e2369ef634a2fc9863 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:14:41 +0100 Subject: [PATCH 076/130] [PM-30307] Session key retrieval redesign for the local backed session storage (#18493) * session key retrieval redesign for the local backed session storage * typo * incorrect substring * get cache edge cases incorrectly handling to null values after removal * test coverage * internal `SessionKeyResolveService` --- .../browser/src/background/main.background.ts | 44 +-- .../services/browser-local-storage.service.ts | 16 + ...cal-backed-session-storage.service.spec.ts | 281 +++++++++++++----- .../local-backed-session-storage.service.ts | 133 +++++++-- 4 files changed, 351 insertions(+), 123 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 8d741039b31..eb6d26357eb 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -139,8 +139,6 @@ import { IpcService, IpcSessionRepository } from "@bitwarden/common/platform/ipc import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; // eslint-disable-next-line no-restricted-imports -- Used for dependency creation import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications"; // eslint-disable-next-line no-restricted-imports -- Needed for service creation import { @@ -565,36 +563,18 @@ export default class MainBackground { this.memoryStorageService = this.memoryStorageForStateProviders; } + this.encryptService = new EncryptServiceImplementation( + this.cryptoFunctionService, + this.logService, + true, + ); + if (BrowserApi.isManifestVersion(3)) { - // Creates a session key for mv3 storage of large memory items - const sessionKey = new Lazy(async () => { - // Key already in session storage - const sessionStorage = new BrowserMemoryStorageService(); - const existingKey = await sessionStorage.get("session-key"); - if (existingKey) { - if (sessionStorage.valuesRequireDeserialization) { - return SymmetricCryptoKey.fromJSON(existingKey); - } - return existingKey; - } - - // New key - const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose( - 128, - "ephemeral", - "bitwarden-ephemeral", - ); - await sessionStorage.save("session-key", derivedKey.toJSON()); - return derivedKey; - }); - this.largeObjectMemoryStorageForStateProviders = new LocalBackedSessionStorageService( - sessionKey, + new BrowserMemoryStorageService(), this.storageService, - // For local backed session storage, we expect that the encrypted data on disk will persist longer than the encryption key in memory - // and failures to decrypt because of that are completely expected. For this reason, we pass in `false` to the `EncryptServiceImplementation` - // so that MAC failures are not logged. - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), + this.keyGenerationService, + this.encryptService, this.platformUtilsService, this.logService, ); @@ -629,12 +609,6 @@ export default class MainBackground { storageServiceProvider, ); - this.encryptService = new EncryptServiceImplementation( - this.cryptoFunctionService, - this.logService, - true, - ); - this.singleUserStateProvider = new DefaultSingleUserStateProvider( storageServiceProvider, stateEventRegistrarService, diff --git a/apps/browser/src/platform/services/browser-local-storage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts index 30454cf6a77..e34c3ac4904 100644 --- a/apps/browser/src/platform/services/browser-local-storage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -15,6 +15,22 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer return await this.getWithRetries(key, 0); } + /** + * Retrieves all storage keys. + * + * Returns all keys stored in local storage when the browser supports the getKeys API (Chrome 130+). + * Returns an empty array on older browser versions where this feature is unavailable. + * + * @returns Array of storage keys, or empty array if the feature is not supported + */ + async getKeys(): Promise { + // getKeys function is only available since Chrome 130 + if ("getKeys" in this.chromeStorageApi) { + return this.chromeStorageApi.getKeys(); + } + return []; + } + private async getWithRetries(key: string, retryNum: number): Promise { // See: https://github.com/EFForg/privacybadger/pull/2980 const MAX_RETRIES = 5; diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index 947fecb5aac..26b51e2fea7 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -1,20 +1,89 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { FakeStorageService, makeEncString } from "@bitwarden/common/spec"; +import { FakeStorageService, makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; +import { StorageService } from "@bitwarden/storage-core"; -import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; +import { + LocalBackedSessionStorageService, + SessionKeyResolveService, +} from "./local-backed-session-storage.service"; + +describe("SessionKeyResolveService", () => { + let storageService: FakeStorageService; + let keyGenerationService: MockProxy; + let sut: SessionKeyResolveService; + + const mockKey = makeSymmetricCryptoKey(); + + beforeEach(() => { + storageService = new FakeStorageService(); + keyGenerationService = mock(); + sut = new SessionKeyResolveService(storageService, keyGenerationService); + }); + + describe("get", () => { + it("returns null when no session key exists", async () => { + const result = await sut.get(); + expect(result).toBeNull(); + }); + + it("returns the session key from storage", async () => { + await storageService.save("session-key", mockKey); + const result = await sut.get(); + expect(result).toEqual(mockKey); + }); + + it("deserializes the session key when storage requires deserialization", async () => { + const mockStorageService = mock(); + Object.defineProperty(mockStorageService, "valuesRequireDeserialization", { + get: () => true, + }); + mockStorageService.get.mockResolvedValue(mockKey.toJSON()); + + const deserializableSut = new SessionKeyResolveService( + mockStorageService, + keyGenerationService, + ); + + const result = await deserializableSut.get(); + + expect(result).toBeInstanceOf(SymmetricCryptoKey); + expect(result?.toJSON()).toEqual(mockKey.toJSON()); + }); + }); + + describe("create", () => { + it("creates a new session key and saves it to storage", async () => { + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: mockKey, + }); + + const result = await sut.create(); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledWith( + 128, + "ephemeral", + "bitwarden-ephemeral", + ); + expect(result).toEqual(mockKey); + expect(await storageService.get("session-key")).toEqual(mockKey.toJSON()); + }); + }); +}); describe("LocalBackedSessionStorage", () => { - const sessionKey = new SymmetricCryptoKey( - Utils.fromUtf8ToArray("00000000000000000000000000000000"), - ); - let localStorage: FakeStorageService; + const sessionKey = makeSymmetricCryptoKey(); + let memoryStorage: MockProxy; + let keyGenerationService: MockProxy; + let localStorage: MockProxy; let encryptService: MockProxy; let platformUtilsService: MockProxy; let logService: MockProxy; @@ -22,14 +91,23 @@ describe("LocalBackedSessionStorage", () => { let sut: LocalBackedSessionStorageService; beforeEach(() => { - localStorage = new FakeStorageService(); + memoryStorage = mock(); + keyGenerationService = mock(); + localStorage = mock(); encryptService = mock(); platformUtilsService = mock(); logService = mock(); + // Default: session key exists + memoryStorage.get.mockResolvedValue(sessionKey); + Object.defineProperty(memoryStorage, "valuesRequireDeserialization", { + get: () => true, + }); + sut = new LocalBackedSessionStorageService( - new Lazy(async () => sessionKey), + memoryStorage, localStorage, + keyGenerationService, encryptService, platformUtilsService, logService, @@ -37,57 +115,79 @@ describe("LocalBackedSessionStorage", () => { }); describe("get", () => { - it("return the cached value when one is cached", async () => { + const encString = makeEncString("encrypted"); + + it("returns the cached value when one is cached", async () => { sut["cache"]["test"] = "cached"; const result = await sut.get("test"); expect(result).toEqual("cached"); }); - it("returns a decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - const result = await sut.get("test"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted")); - }); + it("returns null when both cache and storage are null", async () => { + sut["cache"]["test"] = null; + localStorage.get.mockResolvedValue(null); - it("caches the decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - await sut.get("test"); - expect(sut["cache"]["test"]).toEqual("decrypted"); + const result = await sut.get("test"); + + expect(result).toBeNull(); + expect(localStorage.get).toHaveBeenCalledWith("session_test"); }); it("returns a decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; + localStorage.get.mockResolvedValue(encString.encryptedString); encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); + const result = await sut.get("test"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - (expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey), - expect(result).toEqual("decrypted")); + + expect(encryptService.decryptString).toHaveBeenCalledWith(encString, sessionKey); + expect(result).toEqual("decrypted"); + expect(sut["cache"]["test"]).toEqual("decrypted"); }); - it("caches the decrypted value when one is stored in local storage", async () => { - const encrypted = makeEncString("encrypted"); - localStorage.internalStore["session_test"] = encrypted.encryptedString; - encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); - await sut.get("test"); - expect(sut["cache"]["test"]).toEqual("decrypted"); + it("returns the cached value when cache is populated during storage retrieval", async () => { + localStorage.get.mockImplementation(async () => { + sut["cache"]["test"] = "cached-during-read"; + return encString.encryptedString; + }); + encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted-from-storage")); + + const result = await sut.get("test"); + + expect(result).toEqual("cached-during-read"); + }); + + it("returns the cached value when storage returns null but cache was filled", async () => { + localStorage.get.mockImplementation(async () => { + sut["cache"]["test"] = "cached-during-read"; + return null; + }); + + const result = await sut.get("test"); + + expect(result).toEqual("cached-during-read"); + }); + + it("creates new session key, clears old data, and returns null when session key is missing", async () => { + const newSessionKey = makeSymmetricCryptoKey(); + const clearSpy = jest.spyOn(sut as any, "clear"); + memoryStorage.get.mockResolvedValue(null); + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: newSessionKey, + }); + localStorage.get.mockResolvedValue(null); + localStorage.getKeys.mockResolvedValue([]); + + const result = await sut.get("test"); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled(); + expect(clearSpy).toHaveBeenCalled(); + expect(result).toBeNull(); }); }); describe("has", () => { - it("returns false when the key is not in cache", async () => { - const result = await sut.has("test"); - expect(result).toBe(false); - }); - it("returns true when the key is in cache", async () => { sut["cache"]["test"] = "cached"; const result = await sut.has("test"); @@ -95,21 +195,17 @@ describe("LocalBackedSessionStorage", () => { }); it("returns true when the key is in local storage", async () => { - localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString; + const encString = makeEncString("encrypted"); + localStorage.get.mockResolvedValue(encString.encryptedString); encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted")); const result = await sut.has("test"); expect(result).toBe(true); }); - it.each([null, undefined])("returns false when %s is cached", async (nullish) => { - sut["cache"]["test"] = nullish; - await expect(sut.has("test")).resolves.toBe(false); - }); - it.each([null, undefined])( - "returns false when null is stored in local storage", - async (nullish) => { - localStorage.internalStore["session_test"] = nullish; + "returns false when the key does not exist in local storage (%s)", + async (value) => { + localStorage.get.mockResolvedValue(value); await expect(sut.has("test")).resolves.toBe(false); expect(encryptService.decryptString).not.toHaveBeenCalled(); }, @@ -118,6 +214,7 @@ describe("LocalBackedSessionStorage", () => { describe("save", () => { const encString = makeEncString("encrypted"); + beforeEach(() => { encryptService.encryptString.mockResolvedValue(encString); }); @@ -137,29 +234,44 @@ describe("LocalBackedSessionStorage", () => { }); it("removes the key when saving a null value", async () => { - const spy = jest.spyOn(sut, "remove"); + const removeSpy = jest.spyOn(sut, "remove"); await sut.save("test", null); - expect(spy).toHaveBeenCalledWith("test"); + expect(removeSpy).toHaveBeenCalledWith("test"); }); - it("saves the value to cache", async () => { + it("uses the session key when encrypting", async () => { await sut.save("test", "value"); - expect(sut["cache"]["test"]).toEqual("value"); - }); - it("encrypts and saves the value to local storage", async () => { - await sut.save("test", "value"); + expect(memoryStorage.get).toHaveBeenCalledWith("session-key"); expect(encryptService.encryptString).toHaveBeenCalledWith( JSON.stringify("value"), sessionKey, ); - expect(localStorage.internalStore["session_test"]).toEqual(encString.encryptedString); }); it("emits an update", async () => { - const spy = jest.spyOn(sut["updatesSubject"], "next"); + const updateSpy = jest.spyOn(sut["updatesSubject"], "next"); await sut.save("test", "value"); - expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "save" }); + expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "save" }); + }); + + it("creates a new session key when session key is missing before saving", async () => { + const newSessionKey = makeSymmetricCryptoKey(); + memoryStorage.get.mockResolvedValue(null); + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + salt: "salt", + material: new Uint8Array(16) as any, + derivedKey: newSessionKey, + }); + localStorage.getKeys.mockResolvedValue([]); + + await sut.save("test", "value"); + + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled(); + expect(encryptService.encryptString).toHaveBeenCalledWith( + JSON.stringify("value"), + newSessionKey, + ); }); }); @@ -171,15 +283,50 @@ describe("LocalBackedSessionStorage", () => { }); it("removes the key from local storage", async () => { - localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString; await sut.remove("test"); - expect(localStorage.internalStore["session_test"]).toBeUndefined(); + expect(localStorage.remove).toHaveBeenCalledWith("session_test"); }); it("emits an update", async () => { - const spy = jest.spyOn(sut["updatesSubject"], "next"); + const updateSpy = jest.spyOn(sut["updatesSubject"], "next"); await sut.remove("test"); - expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); + expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); + }); + }); + + describe("sessionStorageKey", () => { + it("prefixes keys with session_ prefix", () => { + expect(sut["sessionStorageKey"]("test")).toBe("session_test"); + }); + }); + + describe("clear", () => { + it("only removes keys with session_ prefix", async () => { + const removeSpy = jest.spyOn(sut, "remove"); + localStorage.getKeys.mockResolvedValue([ + "session_data1", + "session_data2", + "regular_key", + "another_key", + "session_data3", + "my_session_key", + "mysession", + "sessiondata", + "user_session", + ]); + + await sut["clear"](); + + expect(removeSpy).toHaveBeenCalledWith("data1"); + expect(removeSpy).toHaveBeenCalledWith("data2"); + expect(removeSpy).toHaveBeenCalledWith("data3"); + expect(removeSpy).not.toHaveBeenCalledWith("regular_key"); + expect(removeSpy).not.toHaveBeenCalledWith("another_key"); + expect(removeSpy).not.toHaveBeenCalledWith("my_session_key"); + expect(removeSpy).not.toHaveBeenCalledWith("mysession"); + expect(removeSpy).not.toHaveBeenCalledWith("sessiondata"); + expect(removeSpy).not.toHaveBeenCalledWith("user_session"); + expect(removeSpy).toHaveBeenCalledTimes(3); }); }); }); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index d0613ee644c..63a51d28e35 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Subject } from "rxjs"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -12,33 +13,94 @@ import { StorageUpdate, } from "@bitwarden/common/platform/abstractions/storage.service"; import { compareValues } from "@bitwarden/common/platform/misc/compare-values"; -import { Lazy } from "@bitwarden/common/platform/misc/lazy"; import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { StorageService } from "@bitwarden/storage-core"; import { BrowserApi } from "../browser/browser-api"; import { MemoryStoragePortMessage } from "../storage/port-messages"; import { portName } from "../storage/port-name"; +import BrowserLocalStorageService from "./browser-local-storage.service"; + +const SESSION_KEY_PREFIX = "session_"; + +/** + * Manages an ephemeral session key for encrypting session storage items persisted in local storage. + * + * The session key is stored in session storage and automatically cleared when the browser session ends + * (e.g., browser restart, extension reload). When the session key is unavailable, any encrypted items + * in local storage cannot be decrypted and must be cleared to maintain data consistency. + * + * This provides session-scoped security for sensitive data while using persistent local storage as the backing store. + * + * @internal Internal implementation detail. Exported only for testing purposes. + * Do not use this class directly outside of tests. Use LocalBackedSessionStorageService instead. + */ +export class SessionKeyResolveService { + constructor( + private readonly storageService: StorageService, + private readonly keyGenerationService: KeyGenerationService, + ) {} + + /** + * Retrieves the session key from storage. + * + * @return session key or null when not in storage + */ + async get(): Promise { + const key = await this.storageService.get("session-key"); + if (key) { + if (this.storageService.valuesRequireDeserialization) { + return SymmetricCryptoKey.fromJSON(key); + } + return key; + } + return null; + } + + /** + * Creates new session key and adds it to underlying storage. + * + * @return newly created session key + */ + async create(): Promise { + const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose( + 128, + "ephemeral", + "bitwarden-ephemeral", + ); + await this.storageService.save("session-key", derivedKey.toJSON()); + return derivedKey; + } +} + export class LocalBackedSessionStorageService extends AbstractStorageService implements ObservableStorageService { + readonly valuesRequireDeserialization = true; private ports: Set = new Set([]); private cache: Record = {}; private updatesSubject = new Subject(); - readonly valuesRequireDeserialization = true; updates$ = this.updatesSubject.asObservable(); + private readonly sessionKeyResolveService: SessionKeyResolveService; constructor( - private readonly sessionKey: Lazy>, - private readonly localStorage: AbstractStorageService, + private readonly memoryStorage: StorageService, + private readonly localStorage: BrowserLocalStorageService, + private readonly keyGenerationService: KeyGenerationService, private readonly encryptService: EncryptService, private readonly platformUtilsService: PlatformUtilsService, private readonly logService: LogService, ) { super(); + this.sessionKeyResolveService = new SessionKeyResolveService( + this.memoryStorage, + this.keyGenerationService, + ); + BrowserApi.addListener(chrome.runtime.onConnect, (port) => { if (port.name !== portName(chrome.storage.session)) { return; @@ -70,20 +132,20 @@ export class LocalBackedSessionStorageService } async get(key: string, options?: StorageOptions): Promise { - if (this.cache[key] !== undefined) { + if (this.cache[key] != null) { return this.cache[key] as T; } - const value = await this.getLocalSessionValue(await this.sessionKey.get(), key); + const value = await this.getLocalSessionValue(await this.getSessionKey(), key); - if (this.cache[key] === undefined && value !== undefined) { + if (this.cache[key] == null && value != null) { // Cache is still empty and we just got a value from local/session storage, cache it. this.cache[key] = value; return value as T; - } else if (this.cache[key] === undefined && value === undefined) { + } else if (this.cache[key] == null && value == null) { // Cache is still empty and we got nothing from local/session storage, no need to modify cache. return value as T; - } else if (this.cache[key] !== undefined && value !== undefined) { + } else if (this.cache[key] != null && value != null) { // Conflict, somebody wrote to the cache while we were reading from storage // but we also got a value from storage. We assume the cache is more up to date // and use that value. @@ -91,7 +153,7 @@ export class LocalBackedSessionStorageService `Conflict while reading from local session storage, both cache and storage have values. Key: ${key}. Using cached value.`, ); return this.cache[key] as T; - } else if (this.cache[key] !== undefined && value === undefined) { + } else if (this.cache[key] != null && value == null) { // Cache was filled after the local/session storage read completed. We got null // from the storage read, but we have a value from the cache, use that. this.logService.warning( @@ -136,6 +198,44 @@ export class LocalBackedSessionStorageService this.updatesSubject.next({ key, updateType: "remove" }); } + protected broadcastMessage(data: Omit) { + this.ports.forEach((port) => { + this.sendMessageTo(port, data); + }); + } + + private async getSessionKey(): Promise { + const sessionKey = await this.sessionKeyResolveService.get(); + if (sessionKey != null) { + return sessionKey; + } + + // Session key is missing (browser restart/extension reload), so all stored session data + // cannot be decrypted. Clear all items before creating a new session key. + await this.clear(); + + return await this.sessionKeyResolveService.create(); + } + + /** + * Removes all stored session data. + * + * Called when the session key is unavailable (typically after browser restart or extension reload), + * making all encrypted session data unrecoverable. Prevents orphaned encrypted data from accumulating. + */ + private async clear() { + const keys = (await this.localStorage.getKeys()).filter((key) => + key.startsWith(SESSION_KEY_PREFIX), + ); + this.logService.debug( + `[LocalBackedSessionStorageService] Clearing local session storage. Found ${keys}`, + ); + for (const key of keys) { + const keyWithoutPrefix = key.substring(SESSION_KEY_PREFIX.length); + await this.remove(keyWithoutPrefix); + } + } + private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise { const local = await this.localStorage.get(this.sessionStorageKey(key)); if (local == null) { @@ -159,10 +259,7 @@ export class LocalBackedSessionStorageService } const valueJson = JSON.stringify(value); - const encValue = await this.encryptService.encryptString( - valueJson, - await this.sessionKey.get(), - ); + const encValue = await this.encryptService.encryptString(valueJson, await this.getSessionKey()); await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString); } @@ -197,12 +294,6 @@ export class LocalBackedSessionStorageService }); } - protected broadcastMessage(data: Omit) { - this.ports.forEach((port) => { - this.sendMessageTo(port, data); - }); - } - private sendMessageTo( port: chrome.runtime.Port, data: Omit, @@ -214,7 +305,7 @@ export class LocalBackedSessionStorageService } private sessionStorageKey(key: string) { - return `session_${key}`; + return `${SESSION_KEY_PREFIX}${key}`; } private compareValues(value1: T, value2: T): boolean { From 2ada60d1060e1d0a1b2818267a50bb28812fbb64 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:16:39 +0100 Subject: [PATCH 077/130] GH Testing workflow separation to fix OOM kill, timeout issues (#18594) * testing workflow oom kill fix * testing workflow oom kill fix * Adding job to verify all tests complete successfully (cherry picked from commit 845bafeb95fa59cf8d1508d298ccf4dc2684551d) * needs code cov --------- Co-authored-by: Andy Pixley <3723676+pixman20@users.noreply.github.com> --- .github/workflows/test.yml | 147 +++++++++++++++++++++++++++++++++---- 1 file changed, 134 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41b75c5a31d..a0f783bbb36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,13 +14,11 @@ permissions: {} jobs: - testing: - name: Run tests + typecheck: + name: Run typechecking runs-on: ubuntu-22.04 permissions: - checks: write contents: read - pull-requests: write steps: - name: Check out repo @@ -56,17 +54,81 @@ jobs: - name: Run typechecking run: npm run test:types - - name: Run tests + testing: + name: Run tests - ${{ matrix.test-group.name }} + runs-on: ubuntu-22.04 + permissions: + checks: write + contents: read + pull-requests: write + + strategy: + fail-fast: false + matrix: + test-group: + - name: Browser + paths: apps/browser bitwarden_license/bit-browser + artifact: jest-coverage-browser + junit: junit-browser.xml + - name: Web + paths: apps/web bitwarden_license/bit-web + artifact: jest-coverage-web + junit: junit-web.xml + - name: Desktop + paths: apps/desktop + artifact: jest-coverage-desktop + junit: junit-desktop.xml + - name: CLI + paths: apps/cli bitwarden_license/bit-cli + artifact: jest-coverage-cli + junit: junit-cli.xml + - name: Libs + paths: libs bitwarden_license/bit-common + artifact: jest-coverage-libs + junit: junit-libs.xml + + steps: + - name: Check out repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} + + - name: Print environment + run: | + node --version + npm --version + + - name: Install Node dependencies + run: npm ci + + - name: Run tests - ${{ matrix.test-group.name }} # maxWorkers is a workaround for a memory leak that crashes tests in CI: # https://github.com/facebook/jest/issues/9430#issuecomment-1149882002 - run: npm test -- --coverage --maxWorkers=3 + # Reduced to 2 workers and split tests across parallel jobs to prevent OOM kills + run: npm test -- ${{ matrix.test-group.paths }} --coverage --maxWorkers=2 + env: + JEST_JUNIT_OUTPUT_NAME: ${{ matrix.test-group.junit }} - name: Report test results uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: - name: Test Results - path: "junit.xml" + name: Test Results - ${{ matrix.test-group.name }} + path: ${{ matrix.test-group.junit }} reporter: jest-junit fail-on-error: true @@ -78,7 +140,7 @@ jobs: - name: Upload test coverage uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: - name: jest-coverage + name: ${{ matrix.test-group.artifact }} path: ./coverage/lcov.info rust: @@ -183,11 +245,35 @@ jobs: with: persist-credentials: false - - name: Download jest coverage + - name: Download Browser coverage uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - name: jest-coverage - path: ./ + name: jest-coverage-browser + path: ./jest-coverage-browser + + - name: Download Web coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-web + path: ./jest-coverage-web + + - name: Download Desktop coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-desktop + path: ./jest-coverage-desktop + + - name: Download CLI coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-cli + path: ./jest-coverage-cli + + - name: Download Libs coverage + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: jest-coverage-libs + path: ./jest-coverage-libs - name: Download rust coverage uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -199,5 +285,40 @@ jobs: uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: files: | - ./lcov.info + ./jest-coverage-browser/lcov.info + ./jest-coverage-web/lcov.info + ./jest-coverage-desktop/lcov.info + ./jest-coverage-cli/lcov.info + ./jest-coverage-libs/lcov.info ./apps/desktop/desktop_native/lcov.info + + run-tests: # Verifies all required tests complete successfully + name: Run tests + runs-on: ubuntu-24.04 + if: always() + needs: + - typecheck + - testing + - rust + - rust-coverage + - upload-codecov + permissions: + contents: read + + steps: + - name: Check job results + env: + NEEDS: ${{ toJSON(needs) }} + run: | + # Print status of all jobs + echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + + # Collect failed jobs + failed_jobs=$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result != "success") | .key' | tr '\n' ' ') + + if [ -n "$failed_jobs" ]; then + echo "::error::The following jobs failed:$failed_jobs" + exit 1 + fi + + echo "All required jobs passed successfully!" From a4355dbcab2d782d3d506adc4e0adb21fb10b457 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 29 Jan 2026 16:21:46 +0100 Subject: [PATCH 078/130] Ensure "MyVault" is not identified as an organaization (#18643) When creating a new vault item in the My Vault filter owner would not be set. --- apps/desktop/src/vault/app/vault-v3/vault.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 efb7e4de70f..698c442dd8f 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -953,7 +953,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { this.addOrganizationId = collections[0].organizationId; this.addCollectionIds = [this.activeFilter.collectionId]; } - } else if (this.activeFilter.organizationId) { + } else if (this.activeFilter.organizationId && this.activeFilter.organizationId !== "MyVault") { this.addOrganizationId = this.activeFilter.organizationId; } else { // clear out organizationId when the user switches to a personal vault filter From b51d3bf9dad4e8477d268dfaebe2ca962c934d74 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:58:31 -0600 Subject: [PATCH 079/130] [PM-29271] Add referrer checking for vault messages (#18346) * update message from vault handling to check against accounts or message sender * update valid vault referrer logic to check all configured environments --- .../src/background/runtime.background.ts | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index eba6b01fe90..7483e71f87f 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -291,15 +291,21 @@ export default class RuntimeBackground { } break; case "openPopup": - await this.openPopup(); + await this.executeMessageActionOrOpenPopup(msg, this.openPopup.bind(this)); break; case VaultMessages.OpenAtRiskPasswords: { - await this.main.openAtRisksPasswordsPage(); + await this.executeMessageActionOrOpenPopup( + msg, + this.main.openAtRisksPasswordsPage.bind(this), + ); this.announcePopupOpen(); break; } case VaultMessages.OpenBrowserExtensionToUrl: { - await this.main.openTheExtensionToPage(msg.url); + await this.executeMessageActionOrOpenPopup( + msg, + this.main.openTheExtensionToPage.bind(this, msg.url), + ); this.announcePopupOpen(); break; } @@ -374,40 +380,55 @@ export default class RuntimeBackground { * @param message * @returns true if message fails validation */ - private async shouldRejectManyOriginMessage(message: { - webExtSender: chrome.runtime.MessageSender; - }): Promise { + private async executeMessageActionOrOpenPopup( + message: { + webExtSender: chrome.runtime.MessageSender; + }, + messageAction: () => Promise, + ): Promise { + const hasAccounts = await firstValueFrom( + this.accountService.accounts$.pipe(map((a) => Object.keys(a).length > 0)), + ); + + // When there are no accounts associated with the extension, only allow opening the popup + if (!hasAccounts) { + await this.openPopup(); + return; + } + const isValidVaultReferrer = await this.isValidVaultReferrer( Utils.getHostname(message?.webExtSender?.origin), ); - if (isValidVaultReferrer) { - return false; + // When the referrer is not a known vault and the message is external, reject the message + if (!isValidVaultReferrer && isExternalMessage(message)) { + return; } - return isExternalMessage(message); + await messageAction(); } /** - * Validates a message's referrer matches the configured web vault hostname. + * Validates that a referrer hostname matches any of the available regions' and current environment web vault URLs. * - * @param referrer - hostname from message source - * @returns true if referrer matches web vault + * @param referrer - hostname from message source (should not include protocol or path) + * @returns true if referrer matches any known vault hostname, false otherwise */ private async isValidVaultReferrer(referrer: string | null | undefined): Promise { if (!referrer) { return false; } - const env = await firstValueFrom(this.environmentService.environment$); - const vaultUrl = env.getWebVaultUrl(); - const vaultHostname = Utils.getHostname(vaultUrl); + const environment = await firstValueFrom(this.environmentService.environment$); - if (!vaultHostname) { - return false; - } + const regions = this.environmentService.availableRegions(); + const regionVaultUrls = regions.map((r) => r.urls.webVault ?? r.urls.base); + const environmentWebVaultUrl = environment.getWebVaultUrl(); + const messageIsFromKnownVault = [...regionVaultUrls, environmentWebVaultUrl].some( + (webVaultUrl) => Utils.getHostname(webVaultUrl) === referrer, + ); - return vaultHostname === referrer; + return messageIsFromKnownVault; } private async autofillPage(tabToAutoFill: chrome.tabs.Tab) { From 544dcc6757ad943afb2e2b9d2c18af027c7686b6 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:58:57 -0600 Subject: [PATCH 080/130] remove unarchive button when a cipher is deleted (#18575) --- .../vault-v2/view-v2/view-v2.component.html | 2 +- .../vault-v2/view-v2/view-v2.component.spec.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html index 03eb701704f..8ac6de75997 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -26,7 +26,7 @@ } - @if ((archiveFlagEnabled$ | async) && cipher.isArchived) { + @if ((archiveFlagEnabled$ | async) && cipher.isArchived && !cipher.isDeleted) { -

    -
    -
    -
    -
    -
    - {{ "atRiskApplications" | i18n }} -
    - {{ applicationSummary().totalAtRiskApplicationCount }} - {{ - "cardMetrics" | i18n: applicationSummary().totalApplicationCount - }} -
    -
    -

    - -

    -
    -
    -
    -
    -
    + +
    Date: Thu, 29 Jan 2026 09:33:08 -0800 Subject: [PATCH 082/130] [PM-29262] - improve performance of premium spotlight observable (#18490) * improve performance of premium spotlight observable * re-add comment * fix test. remove unused service --- .../vault-v2/vault-v2.component.spec.ts | 25 +++------- .../components/vault-v2/vault-v2.component.ts | 46 ++++++++----------- .../has-items-nudge.service.ts | 13 ++++-- 3 files changed, 35 insertions(+), 49 deletions(-) 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 e3b72c3319f..d7824f3df58 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 @@ -9,7 +9,6 @@ import { BehaviorSubject, Observable, Subject, of } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { NudgeType, NudgesService } from "@bitwarden/angular/vault"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { AutoConfirmExtensionSetupDialogComponent, AutomaticUserConfirmationService, @@ -185,7 +184,7 @@ describe("VaultV2Component", () => { filterVisibilityState$: new BehaviorSubject({}), } as Partial; - const accountActive$ = new BehaviorSubject({ id: "user-1" }); + const activeAccount$ = new BehaviorSubject({ id: "user-1" }); const cipherSvc = { failedToDecryptCiphers$: jest.fn().mockReturnValue(of([])), @@ -222,12 +221,6 @@ describe("VaultV2Component", () => { hasPremiumFromAnySource$: (_: string) => hasPremiumFromAnySource$, }; - const vaultProfileSvc = { - getProfileCreationDate: jest - .fn() - .mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago - }; - const configSvc = { getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), }; @@ -250,16 +243,12 @@ describe("VaultV2Component", () => { { provide: VaultPopupScrollPositionService, useValue: scrollSvc }, { provide: AccountService, - useValue: { activeAccount$: accountActive$ }, + useValue: { activeAccount$ }, }, { provide: CipherService, useValue: cipherSvc }, { provide: DialogService, useValue: dialogSvc }, { provide: IntroCarouselService, useValue: introSvc }, { provide: NudgesService, useValue: nudgesSvc }, - { - provide: VaultProfileService, - useValue: vaultProfileSvc, - }, { provide: VaultPopupCopyButtonsService, useValue: { showQuickCopyActions$: new BehaviorSubject(false) }, @@ -473,7 +462,7 @@ describe("VaultV2Component", () => { it("dismissVaultNudgeSpotlight forwards to NudgesService with active user id", fakeAsync(() => { const spy = jest.spyOn(nudgesSvc, "dismissNudge").mockResolvedValue(undefined); - accountActive$.next({ id: "user-xyz" }); + activeAccount$.next({ id: "user-xyz" }); void component.ngOnInit(); tick(); @@ -485,6 +474,10 @@ describe("VaultV2Component", () => { })); it("accountAgeInDays$ computes integer days since creation", (done) => { + activeAccount$.next({ + id: "user-123", + creationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago + } as any); getObs(component, "accountAgeInDays$").subscribe((days) => { if (days !== null) { expect(days).toBeGreaterThanOrEqual(7); @@ -570,10 +563,6 @@ describe("VaultV2Component", () => { itemsSvc.cipherCount$.next(10); hasPremiumFromAnySource$.next(false); - vaultProfileSvc.getProfileCreationDate = jest - .fn() - .mockResolvedValue(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)); // 3 days ago - (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { return of(type === NudgeType.PremiumUpgrade); }); 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 c58b7b20d2f..51e735fb1ef 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 @@ -5,26 +5,24 @@ import { Component, DestroyRef, effect, inject, OnDestroy, OnInit } from "@angul import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { + BehaviorSubject, combineLatest, distinctUntilChanged, filter, firstValueFrom, - from, map, Observable, shareReplay, switchMap, take, - withLatestFrom, tap, - BehaviorSubject, + withLatestFrom, } from "rxjs"; import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; import { AutoConfirmExtensionSetupDialogComponent, @@ -162,10 +160,6 @@ export class VaultV2Component implements OnInit, OnDestroy { FeatureFlag.BrowserPremiumSpotlight, ); - private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( - switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), - ); - protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; @@ -173,38 +167,39 @@ export class VaultV2Component implements OnInit, OnDestroy { protected hasPremium$ = this.activeUserId$.pipe( switchMap((userId) => this.billingAccountService.hasPremiumFromAnySource$(userId)), ); - protected accountAgeInDays$ = this.activeUserId$.pipe( - switchMap((userId) => { - const creationDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)); - return creationDate$.pipe( - map((creationDate) => { - if (!creationDate) { - return 0; - } - const ageInMilliseconds = Date.now() - creationDate.getTime(); - return Math.floor(ageInMilliseconds / (1000 * 60 * 60 * 24)); - }), - ); + protected accountAgeInDays$ = this.accountService.activeAccount$.pipe( + map((account) => { + if (!account || !account.creationDate) { + return 0; + } + const creationDate = account.creationDate; + const ageInMilliseconds = Date.now() - creationDate.getTime(); + return Math.floor(ageInMilliseconds / (1000 * 60 * 60 * 24)); }), ); protected showPremiumSpotlight$ = combineLatest([ this.premiumSpotlightFeatureFlag$, - this.showPremiumNudgeSpotlight$, + this.activeUserId$.pipe( + switchMap((userId) => + this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId), + ), + ), this.showHasItemsVaultSpotlight$, this.hasPremium$, this.cipherCount$, this.accountAgeInDays$, ]).pipe( - map( - ([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => + map(([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => { + return ( featureFlagEnabled && showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && - age >= 7, - ), + age >= 7 + ); + }), shareReplay({ bufferSize: 1, refCount: true }), ); @@ -263,7 +258,6 @@ export class VaultV2Component implements OnInit, OnDestroy { private router: Router, private autoConfirmService: AutomaticUserConfirmationService, private toastService: ToastService, - private vaultProfileService: VaultProfileService, private billingAccountService: BillingAccountProfileStateService, private liveAnnouncer: LiveAnnouncer, private i18nService: I18nService, diff --git a/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts b/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts index d030b37dbd1..336aead0e8c 100644 --- a/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts +++ b/libs/angular/src/vault/services/custom-nudges-services/has-items-nudge.service.ts @@ -1,8 +1,8 @@ import { inject, Injectable } from "@angular/core"; -import { combineLatest, from, Observable, of, switchMap } from "rxjs"; -import { catchError } from "rxjs/operators"; +import { combineLatest, Observable, of, switchMap } from "rxjs"; +import { catchError, map } from "rxjs/operators"; -import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -20,11 +20,14 @@ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; }) export class HasItemsNudgeService extends DefaultSingleNudgeService { cipherService = inject(CipherService); - vaultProfileService = inject(VaultProfileService); + accountService = inject(AccountService); logService = inject(LogService); nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable { - const profileDate$ = from(this.vaultProfileService.getProfileCreationDate(userId)).pipe( + const profileDate$ = this.accountService.activeAccount$.pipe( + map((account) => { + return account?.creationDate ?? new Date(); + }), catchError(() => { this.logService.error("Error getting profile creation date"); // Default to today to ensure we show the nudge From ac907b90447dbf5b4278388233685f0020218a6b Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Thu, 29 Jan 2026 12:52:26 -0500 Subject: [PATCH 083/130] [PM-29522] Remove @ts-strict-ignore in background/auto-submit-login.background.ts (#18333) * add explicit checks and change remove listener to any in order to match add listener and chrome api * address feedback * when requestInitiator is falsy but active auto submit exists, cleanup should still occur * add documentation to remove listener --- .../auto-submit-login.background.spec.ts | 14 +++++ .../auto-submit-login.background.ts | 52 +++++++++++++------ .../src/platform/browser/browser-api.ts | 3 +- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts b/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts index 82a907a9e43..01767281a20 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.spec.ts @@ -249,6 +249,20 @@ describe("AutoSubmitLoginBackground", () => { false, ); }); + + it("properly cleans up auto-submit workflows when requestInitiator is falsy but active auto-submit hosts exist", async () => { + webRequestDetails.initiator = undefined; + jest + .spyOn(BrowserApi, "getTab") + .mockResolvedValue(mock({ url: validAutoSubmitUrl, id: 1 })); + + triggerWebRequestOnBeforeRequestEvent(webRequestDetails); + await flushPromises(); + + expect(autoSubmitLoginBackground["validAutoSubmitHosts"].has(validAutoSubmitHost)).toBe( + false, + ); + }); }); describe("when the extension is running on a Safari browser", () => { diff --git a/apps/browser/src/autofill/background/auto-submit-login.background.ts b/apps/browser/src/autofill/background/auto-submit-login.background.ts index f593fab2516..07f0b98318a 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { filter, firstValueFrom, of, switchMap } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -64,6 +62,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr this.policyService.policiesByType$(PolicyType.AutomaticAppLogIn, userId), ), getFirstPolicy, + filter((policy): policy is Policy => policy !== undefined), ) .subscribe(this.handleAutoSubmitLoginPolicySubscription.bind(this)); } @@ -165,7 +164,11 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr details: chrome.webRequest.OnBeforeRequestDetails, ): undefined => { const requestInitiator = this.getRequestInitiator(details); - const isValidInitiator = this.isValidInitiator(requestInitiator); + if (!requestInitiator && this.validAutoSubmitHosts.size === 0) { + return; + } + + const isValidInitiator = requestInitiator ? this.isValidInitiator(requestInitiator) : false; if ( this.postRequestEncounteredAfterSubmission(details, isValidInitiator) || @@ -175,14 +178,20 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr return; } - if (isValidInitiator && this.shouldRouteTriggerAutoSubmit(details, requestInitiator)) { + if ( + requestInitiator && + isValidInitiator && + this.shouldRouteTriggerAutoSubmit(details, requestInitiator) + ) { this.setupAutoSubmitFlow(details); return; } - this.disableAutoSubmitFlow(requestInitiator, details).catch((error) => - this.logService.error(error), - ); + if (requestInitiator || this.validAutoSubmitHosts.size > 0) { + this.disableAutoSubmitFlow(requestInitiator || "", details).catch((error) => + this.logService.error(error), + ); + } }; /** @@ -368,8 +377,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr } const tab = await BrowserApi.getTab(details.tabId); - if (this.isValidAutoSubmitHost(tab?.url)) { - this.removeUrlFromAutoSubmitHosts(tab.url); + const tabUrl = tab?.url; + if (tabUrl && this.isValidAutoSubmitHost(tabUrl)) { + this.removeUrlFromAutoSubmitHosts(tabUrl); } }; @@ -427,7 +437,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr */ private getUrlHost = (url: string) => { let parsedUrl = url; - if (!parsedUrl) { + if (!parsedUrl || typeof parsedUrl !== "string") { return ""; } @@ -495,6 +505,10 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr message: AutoSubmitLoginMessage, sender: chrome.runtime.MessageSender, ) => { + if (sender.frameId == null || !sender.tab || !message.pageDetails) { + return; + } + await this.autofillService.doAutoFillOnTab( [ { @@ -515,7 +529,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param sender - The message sender. */ private handleMultiStepAutoSubmitLoginComplete = (sender: chrome.runtime.MessageSender) => { - this.removeUrlFromAutoSubmitHosts(sender.url); + if (sender.url) { + this.removeUrlFromAutoSubmitHosts(sender.url); + } }; /** @@ -526,7 +542,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr */ private async initSafari() { const currentTab = await BrowserApi.getTabFromCurrentWindow(); - if (currentTab) { + if (currentTab?.url && currentTab.id != null && currentTab.id >= 0) { this.setMostRecentIdpHost(currentTab.url, currentTab.id); } @@ -558,7 +574,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr } const tab = await BrowserApi.getTab(activeInfo.tabId); - if (tab) { + if (tab?.url && tab.id != null && tab.id >= 0) { this.setMostRecentIdpHost(tab.url, tab.id); } }; @@ -570,7 +586,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param changeInfo - The change information of the tab. */ private handleSafariTabOnUpdated = (tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => { - if (changeInfo) { + if (changeInfo.url) { this.setMostRecentIdpHost(changeInfo.url, tabId); } }; @@ -626,13 +642,17 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr * @param sender - The message sender. * @param sendResponse - The response callback. */ - private handleExtensionMessage = async ( + private handleExtensionMessage = ( message: AutoSubmitLoginMessage, sender: chrome.runtime.MessageSender, sendResponse: (response?: any) => void, ) => { const { tab, url } = sender; - if (tab?.id !== this.currentAutoSubmitHostData.tabId || !this.isValidAutoSubmitHost(url)) { + if ( + !url || + tab?.id !== this.currentAutoSubmitHostData.tabId || + !this.isValidAutoSubmitHost(url) + ) { return null; } diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index cfc39fa18a1..feefd527636 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -560,7 +560,8 @@ export class BrowserApi { * @param event - The event in which to remove the listener from. * @param callback - The callback you want removed from the event. */ - static removeListener unknown>( + // Chrome's Event.removeListener expects callback args as `any[]` to align with its internal event typings. + static removeListener any>( event: chrome.events.Event, callback: T, ) { From 7f13c6ea671842ee48905ed1a4e87a428f221572 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 29 Jan 2026 13:38:37 -0600 Subject: [PATCH 084/130] PM-30538 replicated app-table-scrollable shared component for milestone 11 (#18655) --- .../applications.component.html | 4 +- .../applications.component.ts | 8 +- ...pp-table-row-scrollable-m11.component.html | 150 ++++++++++++++++++ .../app-table-row-scrollable-m11.component.ts | 47 ++++++ 4 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html create mode 100644 bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index e9084887914..1bfe41901c8 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -35,7 +35,7 @@
    - + >
    } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts index 0a393b26974..8962980c872 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.ts @@ -35,10 +35,8 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { SharedModule } from "@bitwarden/web-vault/app/shared"; import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; -import { - ApplicationTableDataSource, - AppTableRowScrollableComponent, -} from "../shared/app-table-row-scrollable.component"; +import { AppTableRowScrollableM11Component } from "../shared/app-table-row-scrollable-m11.component"; +import { ApplicationTableDataSource } from "../shared/app-table-row-scrollable.component"; import { ReportLoadingComponent } from "../shared/report-loading.component"; export const ApplicationFilterOption = { @@ -62,7 +60,7 @@ export type ApplicationFilterOption = PipesModule, NoItemsModule, SharedModule, - AppTableRowScrollableComponent, + AppTableRowScrollableM11Component, IconButtonModule, TypographyModule, ButtonModule, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html new file mode 100644 index 00000000000..0a72c76a550 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.html @@ -0,0 +1,150 @@ + + + + + + {{ "application" | i18n }} + + {{ "atRiskPasswords" | i18n }} + + {{ "totalPasswords" | i18n }} + {{ "atRiskMembers" | i18n }} + {{ "totalMembers" | i18n }} + + + @if (showRowCheckBox) { + + @if (!row.isMarkedAsCritical) { + + } + @if (row.isMarkedAsCritical) { + + } + + } + @if (!showRowCheckBox) { + + @if (row.isMarkedAsCritical) { + + } + + } + + @if (row.iconCipher) { + + } + + + {{ row.applicationName }} + + + + {{ row.atRiskPasswordCount }} + + + + + {{ row.passwordCount }} + + + + + {{ row.atRiskMemberCount }} + + + + {{ row.memberCount }} + + @if (showRowMenuForCriticalApps) { + + + + + + + } + + + diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts new file mode 100644 index 00000000000..44fed42de65 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable-m11.component.ts @@ -0,0 +1,47 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { MenuModule, TableDataSource, TableModule } from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; +import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module"; + +import { ApplicationTableDataSource } from "./app-table-row-scrollable.component"; + +//TODO: Rename this component to AppTableRowScrollableComponent once milestone 11 is fully rolled out +//TODO: Move definition of ApplicationTableDataSource to this file from app-table-row-scrollable.component.ts + +// 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-table-row-scrollable-m11", + imports: [CommonModule, JslibModule, TableModule, SharedModule, PipesModule, MenuModule], + templateUrl: "./app-table-row-scrollable-m11.component.html", +}) +export class AppTableRowScrollableM11Component { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() + dataSource!: TableDataSource; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() showRowMenuForCriticalApps: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() showRowCheckBox: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() selectedUrls: Set = new Set(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() openApplication: string = ""; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() showAppAtRiskMembers!: (applicationName: string) => void; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() unmarkAsCritical!: (applicationName: string) => void; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() checkboxChange!: (applicationName: string, $event: Event) => void; +} From ae64706256e46fdbdf1dd035d71a3a94a64c34f6 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 29 Jan 2026 13:56:35 -0600 Subject: [PATCH 085/130] [PM-30891] - Create My Items On Restore (#18454) * Added encrypted default collection name to new feature flagged restore user methods/endpoint. * corrected filter to use null check with imperative code --- .../bulk/bulk-restore-revoke.component.ts | 49 +++++++++- .../member-dialog/member-dialog.component.ts | 48 +++++++--- .../member-actions.service.spec.ts | 71 +++++++++++--- .../member-actions/member-actions.service.ts | 23 ++++- .../organization-user-api.service.ts | 25 +++++ .../abstractions/organization-user.service.ts | 7 ++ .../organization-user-bulk-restore.request.ts | 11 +++ .../organization-user-restore.request.ts | 9 ++ .../default-organization-user-api.service.ts | 30 ++++++ .../default-organization-user.service.spec.ts | 95 +++++++++++++++++++ .../default-organization-user.service.ts | 44 ++++++++- libs/common/src/enums/feature-flag.enum.ts | 2 + 12 files changed, 378 insertions(+), 36 deletions(-) create mode 100644 libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts create mode 100644 libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts index dc7b079fefe..154a683b0e1 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.ts @@ -1,10 +1,21 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, Inject } from "@angular/core"; +import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + OrganizationUserApiService, + OrganizationUserService, +} from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { DIALOG_DATA, DialogService } from "@bitwarden/components"; import { BulkUserDetails } from "./bulk-status.component"; @@ -34,10 +45,15 @@ export class BulkRestoreRevokeComponent { error: string; showNoMasterPasswordWarning = false; nonCompliantMembers: boolean = false; + organization$: Observable; constructor( protected i18nService: I18nService, private organizationUserApiService: OrganizationUserApiService, + private organizationUserService: OrganizationUserService, + private accountService: AccountService, + private organizationService: OrganizationService, + private configService: ConfigService, @Inject(DIALOG_DATA) protected data: BulkRestoreDialogParams, ) { this.isRevoking = data.isRevoking; @@ -46,6 +62,18 @@ export class BulkRestoreRevokeComponent { this.showNoMasterPasswordWarning = this.users.some( (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, ); + + this.organization$ = accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => organizationService.organizations$(userId)), + getById(this.organizationId), + map((organization) => { + if (organization == null) { + throw new Error("Organization not found"); + } + return organization; + }), + ); } get bulkTitle() { @@ -83,9 +111,22 @@ export class BulkRestoreRevokeComponent { userIds, ); } else { - return await this.organizationUserApiService.restoreManyOrganizationUsers( - this.organizationId, - userIds, + return await firstValueFrom( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore), + this.organization$, + ]).pipe( + switchMap(([enabled, organization]) => { + if (enabled) { + return this.organizationUserService.bulkRestoreUsers(organization, userIds); + } else { + return this.organizationUserApiService.restoreManyOrganizationUsers( + this.organizationId, + userIds, + ); + } + }), + ), ); } } diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index 1fa4c8bf8f7..6848f76286f 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -17,11 +17,9 @@ import { import { CollectionAdminService, OrganizationUserApiService, + OrganizationUserService, } from "@bitwarden/admin-console/common"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType, OrganizationUserType, @@ -36,8 +34,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { getById } from "@bitwarden/common/platform/misc"; import { DIALOG_DATA, DialogConfig, @@ -197,14 +197,19 @@ export class MemberDialogComponent implements OnDestroy { private toastService: ToastService, private configService: ConfigService, private deleteManagedMemberWarningService: DeleteManagedMemberWarningService, + private organizationUserService: OrganizationUserService, ) { this.organization$ = accountService.activeAccount$.pipe( - switchMap((account) => - organizationService - .organizations$(account?.id) - .pipe(getOrganizationById(this.params.organizationId)) - .pipe(shareReplay({ refCount: true, bufferSize: 1 })), - ), + getUserId, + switchMap((userId) => organizationService.organizations$(userId)), + getById(this.params.organizationId), + map((organization) => { + if (organization == null) { + throw new Error("Organization not found"); + } + return organization; + }), + shareReplay({ refCount: true, bufferSize: 1 }), ); let userDetails$; @@ -633,9 +638,26 @@ export class MemberDialogComponent implements OnDestroy { return; } - await this.organizationUserApiService.restoreOrganizationUser( - this.params.organizationId, - this.params.organizationUserId, + await firstValueFrom( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore), + this.organization$, + this.editParams$, + ]).pipe( + switchMap(([enabled, organization, params]) => { + if (enabled) { + return this.organizationUserService.restoreUser( + organization, + params.organizationUserId, + ); + } else { + return this.organizationUserApiService.restoreOrganizationUser( + params.organizationId, + params.organizationUserId, + ); + } + }), + ), ); this.toastService.showToast({ diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index 423977e73c4..5924c2f7814 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from "@angular/core/testing"; import { MockProxy, mock } from "jest-mock-extended"; -import { of } from "rxjs"; +import { of, throwError } from "rxjs"; import { OrganizationUserApiService, @@ -17,6 +17,7 @@ import { import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -31,6 +32,7 @@ describe("MemberActionsService", () => { let service: MemberActionsService; let organizationUserApiService: MockProxy; let organizationUserService: MockProxy; + let configService: MockProxy; let organizationMetadataService: MockProxy; const organizationId = newGuid() as OrganizationId; @@ -42,6 +44,7 @@ describe("MemberActionsService", () => { beforeEach(() => { organizationUserApiService = mock(); organizationUserService = mock(); + configService = mock(); organizationMetadataService = mock(); mockOrganization = { @@ -65,6 +68,7 @@ describe("MemberActionsService", () => { MemberActionsService, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: OrganizationUserService, useValue: organizationUserService }, + { provide: ConfigService, useValue: configService }, { provide: OrganizationMetadataServiceAbstraction, useValue: organizationMetadataService, @@ -174,25 +178,64 @@ describe("MemberActionsService", () => { }); describe("restoreUser", () => { - it("should successfully restore a user", async () => { - organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined); + describe("when feature flag is enabled", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + }); - const result = await service.restoreUser(mockOrganization, userIdToManage); + it("should call organizationUserService.restoreUser", async () => { + organizationUserService.restoreUser.mockReturnValue(of(undefined)); - expect(result).toEqual({ success: true }); - expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith( - organizationId, - userIdToManage, - ); + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserService.restoreUser).toHaveBeenCalledWith( + mockOrganization, + userIdToManage, + ); + expect(organizationUserApiService.restoreOrganizationUser).not.toHaveBeenCalled(); + }); + + it("should handle errors from organizationUserService.restoreUser", async () => { + const errorMessage = "Restore failed"; + organizationUserService.restoreUser.mockReturnValue( + throwError(() => new Error(errorMessage)), + ); + + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); }); - it("should handle restore errors", async () => { - const errorMessage = "Restore failed"; - organizationUserApiService.restoreOrganizationUser.mockRejectedValue(new Error(errorMessage)); + describe("when feature flag is disabled", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); - const result = await service.restoreUser(mockOrganization, userIdToManage); + it("should call organizationUserApiService.restoreOrganizationUser", async () => { + organizationUserApiService.restoreOrganizationUser.mockResolvedValue(undefined); - expect(result).toEqual({ success: false, error: errorMessage }); + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: true }); + expect(organizationUserApiService.restoreOrganizationUser).toHaveBeenCalledWith( + organizationId, + userIdToManage, + ); + expect(organizationUserService.restoreUser).not.toHaveBeenCalled(); + }); + + it("should handle errors", async () => { + const errorMessage = "Restore failed"; + organizationUserApiService.restoreOrganizationUser.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.restoreUser(mockOrganization, userIdToManage); + + expect(result).toEqual({ success: false, error: errorMessage }); + }); }); }); diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index e8c4a21d675..3b0db124a6b 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable, signal } from "@angular/core"; -import { lastValueFrom, firstValueFrom } from "rxjs"; +import { lastValueFrom, firstValueFrom, switchMap } from "rxjs"; import { OrganizationUserApiService, @@ -10,13 +10,15 @@ import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/abstractions/organization-management-preferences/organization-management-preferences.service"; import { - OrganizationUserType, OrganizationUserStatusType, + OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; @@ -43,6 +45,7 @@ export interface BulkActionResult { export class MemberActionsService { private organizationUserApiService = inject(OrganizationUserApiService); private organizationUserService = inject(OrganizationUserService); + private configService = inject(ConfigService); private organizationMetadataService = inject(OrganizationMetadataServiceAbstraction); private apiService = inject(ApiService); private dialogService = inject(DialogService); @@ -116,7 +119,21 @@ export class MemberActionsService { async restoreUser(organization: Organization, userId: string): Promise { this.startProcessing(); try { - await this.organizationUserApiService.restoreOrganizationUser(organization.id, userId); + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.DefaultUserCollectionRestore).pipe( + switchMap((enabled) => { + if (enabled) { + return this.organizationUserService.restoreUser(organization, userId); + } else { + return this.organizationUserApiService.restoreOrganizationUser( + organization.id, + userId, + ); + } + }), + ), + ); + this.organizationMetadataService.refreshMetadataCache(); return { success: true }; } catch (error) { diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts index cbaece1b442..1dc0e0b3bef 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user-api.service.ts @@ -10,6 +10,8 @@ import { OrganizationUserResetPasswordRequest, OrganizationUserUpdateRequest, } from "../models/requests"; +import { OrganizationUserBulkRestoreRequest } from "../models/requests/organization-user-bulk-restore.request"; +import { OrganizationUserRestoreRequest } from "../models/requests/organization-user-restore.request"; import { OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkResponse, @@ -278,6 +280,18 @@ export abstract class OrganizationUserApiService { */ abstract restoreOrganizationUser(organizationId: string, id: string): Promise; + /** + * Restore an organization user's access to the organization + * @param organizationId - Identifier for the organization the user belongs to + * @param id - Organization user identifier + * @param request - Restore request containing default user collection name + */ + abstract restoreOrganizationUser_vNext( + organizationId: string, + id: string, + request: OrganizationUserRestoreRequest, + ): Promise; + /** * Restore many organization users' access to the organization * @param organizationId - Identifier for the organization the users belongs to @@ -289,6 +303,17 @@ export abstract class OrganizationUserApiService { ids: string[], ): Promise>; + /** + * Restore many organization users' access to the organization + * @param organizationId - Identifier for the organization the users belongs to + * @param request - Restore request containing default user collection name + * @return List of user ids, including both those that were successfully restored and those that had an error + */ + abstract restoreManyOrganizationUsers_vNext( + organizationId: string, + request: OrganizationUserBulkRestoreRequest, + ): Promise>; + /** * Remove an organization user's access to the organization and delete their account data * @param organizationId - Identifier for the organization the user belongs to diff --git a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts index 844a0f412be..03e6840d786 100644 --- a/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/abstractions/organization-user.service.ts @@ -42,4 +42,11 @@ export abstract class OrganizationUserService { organization: Organization, userIdsWithKeys: { id: string; key: string }[], ): Observable>; + + abstract restoreUser(organization: Organization, userId: string): Observable; + + abstract bulkRestoreUsers( + organization: Organization, + userIds: string[], + ): Observable>; } diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts new file mode 100644 index 00000000000..74a91897a58 --- /dev/null +++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-bulk-restore.request.ts @@ -0,0 +1,11 @@ +import { EncString } from "@bitwarden/sdk-internal"; + +export class OrganizationUserBulkRestoreRequest { + userIds: string[]; + defaultUserCollectionName: EncString | undefined; + + constructor(userIds: string[], defaultUserCollectionName?: EncString) { + this.userIds = userIds; + this.defaultUserCollectionName = defaultUserCollectionName; + } +} diff --git a/libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts b/libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts new file mode 100644 index 00000000000..c4607065845 --- /dev/null +++ b/libs/admin-console/src/common/organization-user/models/requests/organization-user-restore.request.ts @@ -0,0 +1,9 @@ +import { EncString } from "@bitwarden/sdk-internal"; + +export class OrganizationUserRestoreRequest { + defaultUserCollectionName: EncString | undefined; + + constructor(defaultUserCollectionName?: EncString) { + this.defaultUserCollectionName = defaultUserCollectionName; + } +} diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts index 536afd2b3f6..e5609f75251 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user-api.service.ts @@ -13,6 +13,8 @@ import { OrganizationUserUpdateRequest, OrganizationUserBulkRequest, } from "../models/requests"; +import { OrganizationUserBulkRestoreRequest } from "../models/requests/organization-user-bulk-restore.request"; +import { OrganizationUserRestoreRequest } from "../models/requests/organization-user-restore.request"; import { OrganizationUserBulkPublicKeyResponse, OrganizationUserBulkResponse, @@ -359,6 +361,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer ); } + restoreOrganizationUser_vNext( + organizationId: string, + id: string, + request: OrganizationUserRestoreRequest, + ): Promise { + return this.apiService.send( + "PUT", + "/organizations/" + organizationId + "/users/" + id + "/restore/vnext", + request, + true, + false, + ); + } + async restoreManyOrganizationUsers( organizationId: string, ids: string[], @@ -373,6 +389,20 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer return new ListResponse(r, OrganizationUserBulkResponse); } + async restoreManyOrganizationUsers_vNext( + organizationId: string, + request: OrganizationUserBulkRestoreRequest, + ): Promise> { + const r = await this.apiService.send( + "PUT", + "/organizations/" + organizationId + "/users/restore", + request, + true, + true, + ); + return new ListResponse(r, OrganizationUserBulkResponse); + } + deleteOrganizationUser(organizationId: string, id: string): Promise { return this.apiService.send( "DELETE", diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts index 982fb3ca5e0..0448b23e4d2 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.spec.ts @@ -61,6 +61,8 @@ describe("DefaultOrganizationUserService", () => { organizationUserApiService = { postOrganizationUserConfirm: jest.fn(), postOrganizationUserBulkConfirm: jest.fn(), + restoreOrganizationUser_vNext: jest.fn(), + restoreManyOrganizationUsers_vNext: jest.fn(), } as any; accountService = { @@ -174,4 +176,97 @@ describe("DefaultOrganizationUserService", () => { }); }); }); + + describe("buildRestoreUserRequest", () => { + beforeEach(() => { + setupCommonMocks(); + }); + + it("should build a restore request with encrypted collection name", (done) => { + service.buildRestoreUserRequest(mockOrganization).subscribe({ + next: (request) => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect(request).toEqual({ + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + }); + done(); + }, + error: done, + }); + }); + }); + + describe("restoreUser", () => { + beforeEach(() => { + setupCommonMocks(); + organizationUserApiService.restoreOrganizationUser_vNext.mockReturnValue(Promise.resolve()); + }); + + it("should restore a user successfully", (done) => { + service.restoreUser(mockOrganization, mockUserId).subscribe({ + next: () => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect(organizationUserApiService.restoreOrganizationUser_vNext).toHaveBeenCalledWith( + mockOrganization.id, + mockUserId, + { + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + }, + ); + done(); + }, + error: done, + }); + }); + }); + + describe("bulkRestoreUsers", () => { + const mockUserIds = ["user-1", "user-2"]; + + const mockBulkResponse = { + data: [ + { id: "user-1", error: null } as OrganizationUserBulkResponse, + { id: "user-2", error: null } as OrganizationUserBulkResponse, + ], + } as ListResponse; + + beforeEach(() => { + setupCommonMocks(); + organizationUserApiService.restoreManyOrganizationUsers_vNext.mockReturnValue( + Promise.resolve(mockBulkResponse), + ); + }); + + it("should bulk restore users successfully", (done) => { + service.bulkRestoreUsers(mockOrganization, mockUserIds).subscribe({ + next: (response) => { + expect(i18nService.t).toHaveBeenCalledWith("myItems"); + expect(encryptService.encryptString).toHaveBeenCalledWith( + mockDefaultCollectionName, + mockOrgKey, + ); + expect( + organizationUserApiService.restoreManyOrganizationUsers_vNext, + ).toHaveBeenCalledWith( + mockOrganization.id, + expect.objectContaining({ + userIds: mockUserIds, + defaultUserCollectionName: mockEncryptedCollectionName.encryptedString, + }), + ); + expect(response).toEqual(mockBulkResponse); + done(); + }, + error: done, + }); + }); + }); }); diff --git a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts index 4f503a92675..d54743e2f7b 100644 --- a/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts +++ b/libs/admin-console/src/common/organization-user/services/default-organization-user.service.ts @@ -1,10 +1,10 @@ import { combineLatest, filter, map, Observable, switchMap } from "rxjs"; import { - OrganizationUserConfirmRequest, - OrganizationUserBulkConfirmRequest, OrganizationUserApiService, + OrganizationUserBulkConfirmRequest, OrganizationUserBulkResponse, + OrganizationUserConfirmRequest, OrganizationUserService, } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -16,6 +16,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { OrganizationId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; +import { OrganizationUserBulkRestoreRequest } from "../models/requests/organization-user-bulk-restore.request"; +import { OrganizationUserRestoreRequest } from "../models/requests/organization-user-restore.request"; + export class DefaultOrganizationUserService implements OrganizationUserService { constructor( protected keyService: KeyService, @@ -83,6 +86,43 @@ export class DefaultOrganizationUserService implements OrganizationUserService { ); } + buildRestoreUserRequest(organization: Organization): Observable { + return this.getEncryptedDefaultCollectionName$(organization).pipe( + map((collectionName) => new OrganizationUserRestoreRequest(collectionName.encryptedString)), + ); + } + + restoreUser(organization: Organization, userId: string): Observable { + return this.buildRestoreUserRequest(organization).pipe( + switchMap((request) => + this.organizationUserApiService.restoreOrganizationUser_vNext( + organization.id, + userId, + request, + ), + ), + ); + } + + bulkRestoreUsers( + organization: Organization, + userIds: string[], + ): Observable> { + return this.getEncryptedDefaultCollectionName$(organization).pipe( + switchMap((collectionName) => { + const request = new OrganizationUserBulkRestoreRequest( + userIds, + collectionName.encryptedString, + ); + + return this.organizationUserApiService.restoreManyOrganizationUsers_vNext( + organization.id, + request, + ); + }), + ); + } + private getEncryptedDefaultCollectionName$(organization: Organization) { return this.orgKey$(organization).pipe( switchMap((orgKey) => diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ac5f3c10260..9d9de56c608 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -13,6 +13,7 @@ export enum FeatureFlag { /* Admin Console Team */ AutoConfirm = "pm-19934-auto-confirm-organization-users", BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration", + DefaultUserCollectionRestore = "pm-30883-my-items-restored-users", MembersComponentRefactor = "pm-29503-refactor-members-inheritance", /* Auth */ @@ -104,6 +105,7 @@ export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.AutoConfirm]: FALSE, [FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE, + [FeatureFlag.DefaultUserCollectionRestore]: FALSE, [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ From 9e36c19515239e94b7c3ff134a71976a23191603 Mon Sep 17 00:00:00 2001 From: sven-bitwarden Date: Thu, 29 Jan 2026 14:11:31 -0600 Subject: [PATCH 086/130] Separates policy response model for individual policy data (#18377) --- .../policies/base-policy-edit.component.ts | 4 ++-- .../remove-unlock-with-pin.component.spec.ts | 11 ++++------- .../policy/policy-api.service.abstraction.ts | 3 ++- .../models/response/policy-status.response.ts | 19 +++++++++++++++++++ .../services/policy/policy-api.service.ts | 3 ++- 5 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 libs/common/src/admin-console/models/response/policy-status.response.ts diff --git a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts index c1b175fa988..08897299d81 100644 --- a/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/base-policy-edit.component.ts @@ -6,7 +6,7 @@ import { Constructor } from "type-fest"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; -import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; +import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogConfig, DialogRef, DialogService } from "@bitwarden/components"; @@ -80,7 +80,7 @@ export abstract class BasePolicyEditDefinition { export abstract class BasePolicyEditComponent implements OnInit { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() policyResponse: PolicyResponse | undefined; + @Input() policyResponse: PolicyStatusResponse | undefined; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() policy: BasePolicyEditDefinition | undefined; diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts index f6df56cd83a..21ab7fc71ba 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/remove-unlock-with-pin.component.spec.ts @@ -4,7 +4,7 @@ import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response"; +import { PolicyStatusResponse } from "@bitwarden/common/admin-console/models/response/policy-status.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -42,8 +42,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => { }); it("input selected on load when policy enabled", async () => { - component.policyResponse = new PolicyResponse({ - id: "policy1", + component.policyResponse = new PolicyStatusResponse({ organizationId: "org1", type: PolicyType.RemoveUnlockWithPin, enabled: true, @@ -63,8 +62,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => { }); it("input not selected on load when policy disabled", async () => { - component.policyResponse = new PolicyResponse({ - id: "policy1", + component.policyResponse = new PolicyStatusResponse({ organizationId: "org1", type: PolicyType.RemoveUnlockWithPin, enabled: false, @@ -84,8 +82,7 @@ describe("RemoveUnlockWithPinPolicyComponent", () => { }); it("turn on message label", async () => { - component.policyResponse = new PolicyResponse({ - id: "policy1", + component.policyResponse = new PolicyStatusResponse({ organizationId: "org1", type: PolicyType.RemoveUnlockWithPin, enabled: false, diff --git a/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts index 79055d3cb11..a044aac9d72 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy-api.service.abstraction.ts @@ -3,10 +3,11 @@ import { PolicyType } from "../../enums"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; import { Policy } from "../../models/domain/policy"; import { PolicyRequest } from "../../models/request/policy.request"; +import { PolicyStatusResponse } from "../../models/response/policy-status.response"; import { PolicyResponse } from "../../models/response/policy.response"; export abstract class PolicyApiServiceAbstraction { - abstract getPolicy: (organizationId: string, type: PolicyType) => Promise; + abstract getPolicy: (organizationId: string, type: PolicyType) => Promise; abstract getPolicies: (organizationId: string) => Promise>; abstract getPoliciesByToken: ( diff --git a/libs/common/src/admin-console/models/response/policy-status.response.ts b/libs/common/src/admin-console/models/response/policy-status.response.ts new file mode 100644 index 00000000000..7e4ff604dd6 --- /dev/null +++ b/libs/common/src/admin-console/models/response/policy-status.response.ts @@ -0,0 +1,19 @@ +import { BaseResponse } from "../../../models/response/base.response"; +import { PolicyType } from "../../enums"; + +export class PolicyStatusResponse extends BaseResponse { + organizationId: string; + type: PolicyType; + data: any; + enabled: boolean; + canToggleState: boolean; + + constructor(response: any) { + super(response); + this.organizationId = this.getResponseProperty("OrganizationId"); + this.type = this.getResponseProperty("Type"); + this.data = this.getResponseProperty("Data"); + this.enabled = this.getResponseProperty("Enabled"); + this.canToggleState = this.getResponseProperty("CanToggleState") ?? true; + } +} diff --git a/libs/common/src/admin-console/services/policy/policy-api.service.ts b/libs/common/src/admin-console/services/policy/policy-api.service.ts index c0a5c74f1e3..dbf12e98860 100644 --- a/libs/common/src/admin-console/services/policy/policy-api.service.ts +++ b/libs/common/src/admin-console/services/policy/policy-api.service.ts @@ -14,6 +14,7 @@ import { PolicyData } from "../../models/data/policy.data"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; import { Policy } from "../../models/domain/policy"; import { PolicyRequest } from "../../models/request/policy.request"; +import { PolicyStatusResponse } from "../../models/response/policy-status.response"; import { PolicyResponse } from "../../models/response/policy.response"; export class PolicyApiService implements PolicyApiServiceAbstraction { @@ -23,7 +24,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction { private accountService: AccountService, ) {} - async getPolicy(organizationId: string, type: PolicyType): Promise { + async getPolicy(organizationId: string, type: PolicyType): Promise { const r = await this.apiService.send( "GET", "/organizations/" + organizationId + "/policies/" + type, From 3ca35e19dc189e75040953551b2cb5fe1b27b857 Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Thu, 29 Jan 2026 12:56:34 -0800 Subject: [PATCH 087/130] [PM-28613] Hardcode min-height to fix vertical squish (#18461) * [pm-28613] Hardcode min-height * [pm-28613] Hardcode icon max-height for Safari Signed-off-by: Ben Brooks --- .../src/autofill/content/components/cipher/cipher-icon.ts | 1 + .../src/autofill/content/components/rows/cipher-item-row.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts b/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts index 66b0d31bddf..5f9b9eb8370 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-icon.ts @@ -27,4 +27,5 @@ export function CipherIcon({ color, size, theme, uri }: CipherIconProps) { const cipherIconStyle = ({ width }: { width: string }) => css` width: ${width}; height: fit-content; + max-height: 24px; /* fallback for Safari */ `; diff --git a/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts b/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts index 0600fc9ac4b..9fcf5c95656 100644 --- a/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts +++ b/apps/browser/src/autofill/content/components/rows/cipher-item-row.ts @@ -51,6 +51,7 @@ const cipherItemRowStyles = ({ theme }: { theme: Theme }) => css` background-color: ${themes[theme].background.DEFAULT}; padding: ${spacing["2"]} ${spacing["3"]}; min-height: min-content; + min-height: 36px; /* fallback for Firefox, which doesn't support min-height: min-content on flex items */ max-height: 52px; overflow-x: hidden; white-space: nowrap; From 50427beba643bd8d5e2e58f73ba3fcdf43948a3d Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Thu, 29 Jan 2026 17:10:37 -0500 Subject: [PATCH 088/130] [PM-29951] add archive flag check to vault-v3 desktop (#18660) * add archive flag check to vault-v3 desktop, sync vault-v2 and vault-v3 --- apps/desktop/src/vault/app/vault-v3/vault.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 698c442dd8f..e3b4493ec7d 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -564,7 +564,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } } - if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + if (userCanArchive && !cipher.isDeleted && !cipher.isArchived) { menu.push({ label: this.i18nService.t("archiveVerb"), click: async () => { @@ -579,7 +579,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { }); } - if (cipher.isArchived) { + if (cipher.isArchived && !cipher.isDeleted) { menu.push({ label: this.i18nService.t("unArchive"), click: async () => { From c3c6346f06dda8bf2255aa66842c6bcfa8f10fd0 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Fri, 30 Jan 2026 04:14:22 -0500 Subject: [PATCH 089/130] [BRE-1527] Renewing Apple app certs (#18668) --- apps/browser/scripts/package-safari.ps1 | 2 +- apps/desktop/scripts/after-pack.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/browser/scripts/package-safari.ps1 b/apps/browser/scripts/package-safari.ps1 index 1df40c68b37..218f7393151 100755 --- a/apps/browser/scripts/package-safari.ps1 +++ b/apps/browser/scripts/package-safari.ps1 @@ -52,7 +52,7 @@ foreach ($subBuildPath in $subBuildPaths) { "--verbose", "--force", "--sign", - "588E3F1724AE018EBA762E42279DAE85B313E3ED", + "A579B6AE496B360642D05B8AB1B650C1B143B770", "--entitlements", $entitlementsPath ) diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index 34378ee092b..091a9ce951e 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -45,7 +45,7 @@ async function run(context) { if (process.env.GITHUB_ACTIONS === "true") { if (is_mas) { id = is_mas_dev - ? "588E3F1724AE018EBA762E42279DAE85B313E3ED" + ? "A579B6AE496B360642D05B8AB1B650C1B143B770" : "3rd Party Mac Developer Application: Bitwarden Inc"; } else { id = "Developer ID Application: 8bit Solutions LLC"; From 8cce4e08f0aea1e1597ee744ff7a16f0be73463a Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:17:07 +0100 Subject: [PATCH 090/130] Autosync the updated translations (#18665) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/az/messages.json | 2 +- apps/desktop/src/locales/es/messages.json | 70 ++++++++++---------- apps/desktop/src/locales/pt_PT/messages.json | 2 +- apps/desktop/src/locales/ru/messages.json | 32 ++++----- apps/desktop/src/locales/zh_CN/messages.json | 4 +- 5 files changed, 55 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index d9709cf308f..f06aa73e545 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -2093,7 +2093,7 @@ "message": "Element birdəfəlik silindi" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arxivlənmiş element bərpa edildi" }, "restoredItem": { "message": "Element bərpa edildi" diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 7620ed56bc1..1f2f3f27d08 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "El Send se borrará permanentemente en esta fecha.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "Archivo a compartir" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Ocultar texto por defecto" }, "hideYourEmail": { "message": "Hide your email address from viewers." }, "limitSendViews": { - "message": "Limit views" + "message": "Limitar visualizaciones" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "$ACCESSCOUNT$ visualizaciones restantes", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,45 +127,45 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Nadie puede ver este Send tras alcanzar el límite.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Nota privada" }, "sendDetails": { - "message": "Send details", + "message": "Detalles del Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Añade una contraseña opcional para que los destinatarios accedan a este Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Texto a compartir" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Nuevo Send de Texto", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Nuevo Send de Archivo", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Editar Send de Texto", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Editar Send de Archivo", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "¿Estás seguro de que quieres eliminar permanentemente este Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { - "message": "New", + "message": "Nuevo", "description": "for adding new items" }, "newUri": { @@ -1844,19 +1844,19 @@ "message": "Exportar desde" }, "exportNoun": { - "message": "Export", + "message": "Exportación", "description": "The noun form of the word Export" }, "exportVerb": { - "message": "Export", + "message": "Exportar", "description": "The verb form of the word Export" }, "importNoun": { - "message": "Import", + "message": "Importación", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Importar", "description": "The verb form of the word Import" }, "fileFormat": { @@ -2483,7 +2483,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLink": { - "message": "Copy Send link", + "message": "Copiar enlace del Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "copySendLinkToClipboard": { @@ -2969,7 +2969,7 @@ "message": "Generar un alias de correo electrónico con un servicio de reenvío externo." }, "forwarderDomainName": { - "message": "Email domain", + "message": "Dominio de correo electrónico", "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { @@ -4120,7 +4120,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsBodyNoSearchResults": { - "message": "Clear filters or try another search term" + "message": "Limpia los filtros o prueba otro término de búsqueda" }, "generatorNudgeTitle": { "message": "Crear contraseñas rápidamente" @@ -4189,7 +4189,7 @@ "example": "Store your keys and connect with the SSH agent for fast, encrypted authentication. Learn more about SSH agent" }, "aboutThisSetting": { - "message": "About this setting" + "message": "Acerca de este ajuste" }, "permitCipherDetailsDescription": { "message": "Bitwarden will use saved login URIs to identify which icon or change password URL should be used to improve your experience. No information is collected or saved when you use this service." @@ -4380,43 +4380,43 @@ "message": "Desarchivar" }, "archived": { - "message": "Archived" + "message": "Archivados" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Elementos archivados" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "No hay elementos archivados" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Los elementos archivados aparecerán aquí y se excluirán de los resultados de búsqueda generales y de sugerencias de autocompletado." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "El elemento fue archivado" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "El elemento fue desarchivado" }, "archiveItem": { - "message": "Archive item" + "message": "Archivar elemento" }, "archiveItemDialogContent": { - "message": "Once archived, this item will be excluded from search results and autofill suggestions." + "message": "Una vez archivado, este elemento se excluirá de los resultados de búsqueda y de sugerencias de autocompletado." }, "unArchiveAndSave": { - "message": "Unarchive and save" + "message": "Desarchivar y guardar" }, "restartPremium": { "message": "Restart Premium" }, "premiumSubscriptionEnded": { - "message": "Your Premium subscription ended" + "message": "Tu suscripción Premium ha terminado" }, "premiumSubscriptionEndedDesc": { "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." }, "itemRestored": { - "message": "Item has been restored" + "message": "El elemento se ha restaurado" }, "zipPostalCodeLabel": { "message": "ZIP / Código postal" diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 2eee6006d30..4dba846d6d7 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -3499,7 +3499,7 @@ } }, "inputMinValue": { - "message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.", + "message": "O valor introduzido deve ser, no mínimo, $MIN$.", "placeholders": { "min": { "content": "$1", diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 71e0d030702..6cced75b1ba 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -101,23 +101,23 @@ } }, "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", + "message": "С этой даты Send будет удалена навсегда.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "fileToShare": { - "message": "File to share" + "message": "Файл для отправки" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "Скрыть текст по умолчанию" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "Скрыть ваш email от просматривающих." }, "limitSendViews": { - "message": "Limit views" + "message": "Лимит просмотров" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "Осталось просмотров: $ACCESSCOUNT$", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -127,41 +127,41 @@ } }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "Никто не сможет просмотреть эту Send после лимита просмотров.", "description": "Displayed under the limit views field on Send" }, "privateNote": { - "message": "Private note" + "message": "Личная заметка" }, "sendDetails": { - "message": "Send details", + "message": "Информация о Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Добавьте необязательный пароль для доступа получателей к этой Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "Текст для отправки" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Новая текстовая Send", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Новая файловая Send", "description": "Header for new file send" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Изменить текстовую Send", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Изменить файловую Send", "description": "Header for edit file send" }, "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", + "message": "Вы действительно хотите безвозвратно удалить эту Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "new": { diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 1e7f860a65f..28fc1a422d7 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1772,7 +1772,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "baseDomain": { "message": "基础域名", @@ -2362,7 +2362,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "allSends": { - "message": "所有的 Send", + "message": "所有 Send", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeFile": { From 092a5a159f39968bbab8d18406b6a3af55d0d517 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:30:36 +0100 Subject: [PATCH 091/130] Autosync the updated translations (#18666) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/ar/messages.json | 29 ++++++- apps/browser/src/_locales/az/messages.json | 39 ++++++++-- apps/browser/src/_locales/be/messages.json | 29 ++++++- apps/browser/src/_locales/bg/messages.json | 35 ++++++++- apps/browser/src/_locales/bn/messages.json | 29 ++++++- apps/browser/src/_locales/bs/messages.json | 29 ++++++- apps/browser/src/_locales/ca/messages.json | 29 ++++++- apps/browser/src/_locales/cs/messages.json | 29 ++++++- apps/browser/src/_locales/cy/messages.json | 29 ++++++- apps/browser/src/_locales/da/messages.json | 29 ++++++- apps/browser/src/_locales/de/messages.json | 35 ++++++++- apps/browser/src/_locales/el/messages.json | 29 ++++++- apps/browser/src/_locales/en_GB/messages.json | 29 ++++++- apps/browser/src/_locales/en_IN/messages.json | 29 ++++++- apps/browser/src/_locales/es/messages.json | 29 ++++++- apps/browser/src/_locales/et/messages.json | 29 ++++++- apps/browser/src/_locales/eu/messages.json | 29 ++++++- apps/browser/src/_locales/fa/messages.json | 29 ++++++- apps/browser/src/_locales/fi/messages.json | 29 ++++++- apps/browser/src/_locales/fil/messages.json | 29 ++++++- apps/browser/src/_locales/fr/messages.json | 29 ++++++- apps/browser/src/_locales/gl/messages.json | 29 ++++++- apps/browser/src/_locales/he/messages.json | 29 ++++++- apps/browser/src/_locales/hi/messages.json | 75 +++++++++++++------ apps/browser/src/_locales/hr/messages.json | 29 ++++++- apps/browser/src/_locales/hu/messages.json | 29 ++++++- apps/browser/src/_locales/id/messages.json | 29 ++++++- apps/browser/src/_locales/it/messages.json | 29 ++++++- apps/browser/src/_locales/ja/messages.json | 29 ++++++- apps/browser/src/_locales/ka/messages.json | 29 ++++++- apps/browser/src/_locales/km/messages.json | 29 ++++++- apps/browser/src/_locales/kn/messages.json | 29 ++++++- apps/browser/src/_locales/ko/messages.json | 29 ++++++- apps/browser/src/_locales/lt/messages.json | 29 ++++++- apps/browser/src/_locales/lv/messages.json | 35 ++++++++- apps/browser/src/_locales/ml/messages.json | 29 ++++++- apps/browser/src/_locales/mr/messages.json | 29 ++++++- apps/browser/src/_locales/my/messages.json | 29 ++++++- apps/browser/src/_locales/nb/messages.json | 29 ++++++- apps/browser/src/_locales/ne/messages.json | 29 ++++++- apps/browser/src/_locales/nl/messages.json | 35 ++++++++- apps/browser/src/_locales/nn/messages.json | 29 ++++++- apps/browser/src/_locales/or/messages.json | 29 ++++++- apps/browser/src/_locales/pl/messages.json | 31 +++++++- apps/browser/src/_locales/pt_BR/messages.json | 35 ++++++++- apps/browser/src/_locales/pt_PT/messages.json | 31 +++++++- apps/browser/src/_locales/ro/messages.json | 29 ++++++- apps/browser/src/_locales/ru/messages.json | 35 ++++++++- apps/browser/src/_locales/si/messages.json | 29 ++++++- apps/browser/src/_locales/sk/messages.json | 29 ++++++- apps/browser/src/_locales/sl/messages.json | 31 +++++++- apps/browser/src/_locales/sr/messages.json | 29 ++++++- apps/browser/src/_locales/sv/messages.json | 35 ++++++++- apps/browser/src/_locales/ta/messages.json | 29 ++++++- apps/browser/src/_locales/te/messages.json | 29 ++++++- apps/browser/src/_locales/th/messages.json | 29 ++++++- apps/browser/src/_locales/tr/messages.json | 35 ++++++++- apps/browser/src/_locales/uk/messages.json | 35 ++++++++- apps/browser/src/_locales/vi/messages.json | 29 ++++++- apps/browser/src/_locales/zh_CN/messages.json | 47 +++++++++--- apps/browser/src/_locales/zh_TW/messages.json | 29 ++++++- apps/browser/store/locales/ru/copy.resx | 28 +++---- 62 files changed, 1789 insertions(+), 142 deletions(-) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 937672bfd60..aad76b885ff 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -990,6 +990,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -2048,6 +2054,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 58c9b5a0cb8..f1bcb822906 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -29,7 +29,7 @@ "message": "Keçid açarı ilə giriş et" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Kilidi keçid açarı ilə aç" }, "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" @@ -990,6 +990,12 @@ "no": { "message": "Xeyr" }, + "noAuth": { + "message": "Keçidə sahib olan hər kəs" + }, + "anyOneWithPassword": { + "message": "Sizin təyin etdiyiniz parola sahib hər kəs" + }, "location": { "message": "Yerləşmə" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -2477,7 +2486,7 @@ "message": "Element birdəfəlik silindi" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arxivlənmiş element bərpa edildi" }, "restoreItem": { "message": "Elementi bərpa et" @@ -3371,10 +3380,10 @@ "message": "Xəta" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Kilid keçid açarı ilə açılmadı. Lütfən yenidən sınayın, ya da başqa kilid açma üsulunu sınayın." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Kilidi açmaq üçün PRF dəstəkli keçid açarı yoxdur. Lütfən əvvəlcə keçid açarı ilə giriş edin." }, "decryptionError": { "message": "Şifrə açma xətası" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Qoşmanı endir" + }, "downloadBitwarden": { "message": "Bitwarden-i endir" }, @@ -5683,7 +5695,7 @@ "message": "Ekstra enli" }, "narrow": { - "message": "Narrow" + "message": "Dar" }, "sshKeyWrongPassword": { "message": "Daxil etdiyiniz parol yanlışdır." @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Yan naviqasiyanı yeni. ölçüləndir" + }, + "whoCanView": { + "message": "Kimlər baxa bilər" + }, + "specificPeople": { + "message": "Xüsusi insanlar" + }, + "emailVerificationDesc": { + "message": "Bu Send keçidini paylaşdıqdan sonra, bu \"Send\"ə baxması üçün insanlar e-poçtlarını bir kodla doğrulamalıdırlar." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Birdən çox e-poçtu daxil edərkən vergül istifadə edin." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 68277cfeb00..8ccc68a9b41 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 05ee1fc5765..cbe9a323b53 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -29,7 +29,7 @@ "message": "Вписване със секретен ключ" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Отключване със секретен ключ" }, "useSingleSignOn": { "message": "Използване на еднократна идентификация" @@ -990,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -3371,10 +3380,10 @@ "message": "Грешка" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Няма секретни ключове с включено PRF, налични за отключване. Първо се впишете със секретен ключ." }, "decryptionError": { "message": "Грешка при дешифриране" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Сваляне на прикачения файл" + }, "downloadBitwarden": { "message": "Сваляне на Битуорден" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Преоразмеряване на страничната навигация" + }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index fa4d93fa9ee..866743a9ccf 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -990,6 +990,12 @@ "no": { "message": "না" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 7eb327b034a..e6d4e8439df 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 3a9333b5471..f62ffca935f 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicació" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 46618df6257..9fdcce3bf04 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stáhnout přílohu" + }, "downloadBitwarden": { "message": "Stáhnout Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Změnit velikost boční navigace" + }, + "whoCanView": { + "message": "Kdo může zobrazit" + }, + "specificPeople": { + "message": "Vybraní lidé" + }, + "emailVerificationDesc": { + "message": "Po sdílení tohoto odkazu Send budou muset jednotlivci ověřit svůj e-mail pomocí kódu pro zobrazení tohoto Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadejte více e-mailů oddělených čárkou." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index d765b7d8a10..6d703ca1b5c 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Na" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lleoliad" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 5add4d4b10c..171fc415913 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 8579ebdee3e..99e195bf194 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -29,7 +29,7 @@ "message": "Mit Passkey anmelden" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Mit Passkey entsperren" }, "useSingleSignOn": { "message": "Single Sign-On verwenden" @@ -990,6 +990,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Standort" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -3371,10 +3380,10 @@ "message": "Fehler" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Entsperren mit Passkey fehlgeschlagen. Bitte versuche es erneut oder verwende eine andere Entsperrmethode." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Es sind keine PRF-fähigen Passkeys zum Entsperren verfügbar. Bitte melde dich zuerst mit einem Passkey an." }, "decryptionError": { "message": "Entschlüsselungsfehler" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Anhang herunterladen" + }, "downloadBitwarden": { "message": "Bitwarden herunterladen" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Größe der Seitennavigation ändern" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index d1eebc0362c..4d94073b4ae 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Λήψη του Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 68cf36cacde..63cd0f56290 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 216db1911f2..b02ba84451d 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 6eca24db96e..fb15597505c 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicación" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Descargar Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 72f9c553569..9623ffafca6 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 04e673d2230..0f614cdb42f 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index a3ea290de39..e6e5ab0038a 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -990,6 +990,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "بارگیری Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 0e19e256714..7587f546afa 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -990,6 +990,12 @@ "no": { "message": "En" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Sähköposti" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Lataa Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index b44f5210ccd..4c906fcd0b6 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Mag-email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 6b5348f564f..717bc742fab 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Emplacement" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Téléphone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Télécharger Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index faf9faf755d..3f622477dfe 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 3d953f508a1..e2902774bf8 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -990,6 +990,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -2048,6 +2054,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "הורד את Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index ea0eb362a0d..6e3db97b75a 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -3,7 +3,7 @@ "message": "bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "बिटवार्डन लोगो" }, "extName": { "message": "बिटवार्डन पासवर्ड मैनेजर", @@ -26,16 +26,16 @@ "message": "बिटवार्डन का परिचय" }, "logInWithPasskey": { - "message": "Log in with passkey" + "message": "पासकी से लॉग इन करें" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "पासकी से अनलॉक करें" }, "useSingleSignOn": { "message": "सिंगल साइन-ऑन प्रयोग करें" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "आपके संगठन को सिंगल साइन-ऑन करना आवश्यक है।" }, "welcomeBack": { "message": "आपका पुन: स्वागत है!" @@ -71,7 +71,7 @@ "message": "मास्टर पासवर्ड संकेत आपको भूल जाने की अवस्था में पासवर्ड को याद करने में सहायता करता है।" }, "masterPassHintText": { - "message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.", + "message": "अगर आप अपना पासवर्ड भूल गए हैं, तो पासवर्ड संकेत आपके ईमेल पर भेजा जा सकता है। $CURRENT$/$MAXIMUM$ अक्षर अधिकतम।", "placeholders": { "current": { "content": "$1", @@ -90,7 +90,7 @@ "message": "Master Password Hint (optional)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "पासवर्ड की मज़बूती का स्कोर $SCORE$", "placeholders": { "score": { "content": "$1", @@ -99,10 +99,10 @@ } }, "joinOrganization": { - "message": "Join organization" + "message": "ऑर्गनाइज़ेशन में शामिल हों" }, "joinOrganizationName": { - "message": "Join $ORGANIZATIONNAME$", + "message": "$ORGANIZATIONNAME$ से जुड़ें", "placeholders": { "organizationName": { "content": "$1", @@ -111,7 +111,7 @@ } }, "finishJoiningThisOrganizationBySettingAMasterPassword": { - "message": "Finish joining this organization by setting a master password." + "message": "मास्टर पासवर्ड सेट करके इस ऑर्गनाइज़ेशन से जुड़ने की प्रक्रिया पूरी करें।" }, "tab": { "message": "टैब" @@ -138,7 +138,7 @@ "message": "Copy Password" }, "copyPassphrase": { - "message": "Copy passphrase" + "message": "पासफ़्रेज़ कॉपी करें" }, "copyNote": { "message": "Copy Note" @@ -162,22 +162,22 @@ "message": "कंपनी के नाम को कॉपी करें" }, "copySSN": { - "message": "Copy Social Security number" + "message": "सामाजिक सुरक्षा संख्या या आधारकार्ड संख्या कॉपी करें" }, "copyPassportNumber": { - "message": "Copy passport number" + "message": "पासपोर्ट नंबर कॉपी करें" }, "copyLicenseNumber": { "message": "Copy license number" }, "copyPrivateKey": { - "message": "Copy private key" + "message": "प्राइवेट की कॉपी करें" }, "copyPublicKey": { "message": "Copy public key" }, "copyFingerprint": { - "message": "Copy fingerprint" + "message": "फिंगरप्रिंट कॉपी करें" }, "copyCustomField": { "message": "Copy $FIELD$", @@ -195,11 +195,11 @@ "message": "Copy notes" }, "copy": { - "message": "Copy", + "message": "कॉपी करें", "description": "Copy to clipboard" }, "fill": { - "message": "Fill", + "message": "भरें", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, "autoFill": { @@ -215,7 +215,7 @@ "message": "स्वचालित पहचान विवरण" }, "fillVerificationCode": { - "message": "Fill verification code" + "message": "सत्यापन कोड भरें" }, "fillVerificationCodeAria": { "message": "Fill Verification Code", @@ -261,16 +261,16 @@ "message": "Add Item" }, "accountEmail": { - "message": "Account email" + "message": "अकाउंट का ईमेल" }, "requestHint": { - "message": "Request hint" + "message": "संकेत का अनुरोध करें" }, "requestPasswordHint": { - "message": "Request password hint" + "message": "पासवर्ड संकेत का अनुरोध करें" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { - "message": "Enter your account email address and your password hint will be sent to you" + "message": "अपना अकाउंट ईमेल पता डालें और आपको आपका पासवर्ड संकेत भेज दिया जाएगा" }, "getMasterPasswordHint": { "message": "मास्टर पासवर्ड संकेत प्राप्त करें" @@ -297,7 +297,7 @@ "message": "Change Master Password" }, "continueToWebApp": { - "message": "Continue to web app?" + "message": "वेब ऐप पर जारी रखें?" }, "continueToWebAppDesc": { "message": "Explore more features of your Bitwarden account on the web app." @@ -368,7 +368,7 @@ "message": "Free Bitwarden Families" }, "freeBitwardenFamiliesPageDesc": { - "message": "You are eligible for Free Bitwarden Families. Redeem this offer today in the web app." + "message": "आप फ्री बिटवर्डन फैमिलीज़ के लिए एलिजिबल हैं। इस ऑफर को आज ही वेब ऐप में रिडीम करें।" }, "version": { "message": "संस्करण" @@ -990,6 +990,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "अटैचमेंट डाउनलोड करें" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index b7dbed3dcc0..cd5087494f6 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Preuzmi Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index fb9e327337c..4fbedfa9cef 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Nem" }, + "noAuth": { + "message": "Bárki ezzel a hivatkozással" + }, + "anyOneWithPassword": { + "message": "Bárki az általam beállított jelszóval" + }, "location": { "message": "Hely" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Melléklet letöltése" + }, "downloadBitwarden": { "message": "Bitwarden letöltése" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Oldalnavigáció átméretezés" + }, + "whoCanView": { + "message": "Ki láthatja" + }, + "specificPeople": { + "message": "Adott személyek" + }, + "emailVerificationDesc": { + "message": "A Send hivatkozás megosztása után a személyeknek ellenőrizniük kell email címüket egy kóddal a Send megtekintéséhez." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Írjunk be több email címet vesszővel elválasztva." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 064e67eb76f..ec51aa90d4f 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Unduh Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 3e47b38f141..a0eae61b4e4 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Luogo" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Scarica Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Ridimensiona la navigazione laterale" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 9784ad44f2a..bf05a524db9 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -990,6 +990,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -2048,6 +2054,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden をダウンロード" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index d74b4f225fe..1632e52d2f2 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -990,6 +990,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index c15ab367666..4c36a852f6a 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 20e1cec5280..069afdef7d2 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -990,6 +990,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 8cedaf14acc..d4ec9c8aab4 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -990,6 +990,12 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index eac510ea668..ee51489cdaa 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "El. paštas" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonas" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 6c5ee5adb98..26460353ac3 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -29,7 +29,7 @@ "message": "Pieteikties ar piekļuves atslēgu" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Atslēgt ar piekļuves atslēgu" }, "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" @@ -990,6 +990,12 @@ "no": { "message": "Nē" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Atrašanās vieta" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -3371,10 +3380,10 @@ "message": "Kļūda" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Neizdevās atslēgt ar piekļuves atslēgu. Lūgums mēģināt vēlreiz vai izmantot citu atslēgšanas veidu." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Atslēgšanai nav pieejama neviena PRF iespējota piekļuves atslēga. Lūgums vispirms pieteikties ar piekļuves atslēgu." }, "decryptionError": { "message": "Atšifrēšanas kļūda" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Lejupielādēt pielikumu" + }, "downloadBitwarden": { "message": "Lejupielādē Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Mainīt sānu pārvietošanās joslas izmēru" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 35ff7b94d4c..678a10073ff 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -990,6 +990,12 @@ "no": { "message": "തെറ്റ്" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index bae23dcd94d..aeffb274db4 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index c15ab367666..4c36a852f6a 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 993d7a1f0db..8ecd57508d5 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Last ned Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index c15ab367666..4c36a852f6a 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 504868fc5c8..b3875cf07e3 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -29,7 +29,7 @@ "message": "Inloggen met passkey" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Ontgrendelen met passkey" }, "useSingleSignOn": { "message": "Single sign-on gebruiken" @@ -990,6 +990,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locatie" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoonnummer" }, @@ -3371,10 +3380,10 @@ "message": "Fout" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Ontgrendelen met passkey mislukt. Probeer het opnieuw of gebruik een andere ontgrendelingsmethode." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Er zijn geen PRF-ingeschakelde passkeys beschikbaar om te ontgrendelen. Log eerst in met een passkey." }, "decryptionError": { "message": "Ontsleutelingsfout" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Bijlage downloaden" + }, "downloadBitwarden": { "message": "Bitwarden downloaden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Formaat zijnavigatie wijzigen" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index c15ab367666..4c36a852f6a 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index c15ab367666..4c36a852f6a 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index f8d8d6bfd69..7979214b3e9 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Numer telefonu" }, @@ -4845,7 +4854,7 @@ "message": "Konsola administratora" }, "admin": { - "message": "Admin" + "message": "Administrator" }, "automaticUserConfirmation": { "message": "Automatic user confirmation" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Pobierz załącznik" + }, "downloadBitwarden": { "message": "Pobierz Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Zmień rozmiar nawigacji bocznej" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index a83d15be1b1..5d041cdb9bb 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -29,7 +29,7 @@ "message": "Conectar-se com chave de acesso" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Desbloquear com chave de acesso" }, "useSingleSignOn": { "message": "Usar autenticação única" @@ -990,6 +990,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Localização" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -3371,10 +3380,10 @@ "message": "Erro" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Falha no desbloqueio com a chave de acesso. Tente novamente ou use outro método de desbloqueio." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Nenhuma chave de acesso com PRF está disponível para desbloqueio. Conecte-se com uma chave de acesso primeiro." }, "decryptionError": { "message": "Erro de descriptografia" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Baixar anexo" + }, "downloadBitwarden": { "message": "Baixar o Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Redimensionar navegação lateral" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 2b40e2003a5..a5c30c75fc4 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Qualquer pessoa com o link" + }, + "anyOneWithPassword": { + "message": "Qualquer pessoa com uma palavra-passe definida por si" + }, "location": { "message": "Localização" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -4067,7 +4076,7 @@ } }, "inputMinValue": { - "message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.", + "message": "O valor introduzido deve ser, no mínimo, $MIN$.", "placeholders": { "min": { "content": "$1", @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Transferir anexo" + }, "downloadBitwarden": { "message": "Descarregar o Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Redimensionar navegação lateral" + }, + "whoCanView": { + "message": "Quem pode ver" + }, + "specificPeople": { + "message": "Pessoas específicas" + }, + "emailVerificationDesc": { + "message": "Após partilhar este Send através do link, os indivíduos terão de verificar o e-mail com um código para poderem ver este Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Introduza vários e-mails, separados por vírgula." + }, + "emailPlaceholder": { + "message": "utilizador@bitwarden.com , utilizador@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index b071d8c765e..8f9f9273d96 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index c2b09803c06..bbb507743da 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -29,7 +29,7 @@ "message": "Войти с passkey" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Разблокировать при помощи passkey" }, "useSingleSignOn": { "message": "Использовать единый вход" @@ -990,6 +990,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -3371,10 +3380,10 @@ "message": "Ошибка" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Для разблокировки недоступны passkeys с поддержкой PRF. Пожалуйста, сначала авторизуйтесь, используя passkey." }, "decryptionError": { "message": "Ошибка расшифровки" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Скачать вложение" + }, "downloadBitwarden": { "message": "Скачать Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Изменить размер боковой навигации" + }, + "whoCanView": { + "message": "Кто может просматривать" + }, + "specificPeople": { + "message": "Конкретные пользователи" + }, + "emailVerificationDesc": { + "message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введите несколько email, разделяя их запятой." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index c2451a18133..e20a2b6f4f1 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -990,6 +990,12 @@ "no": { "message": "නැත" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "ඊ-තැපැල්" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථන" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 5e1511eebac..ced5f612935 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Ktokoľvek s odkazom" + }, + "anyOneWithPassword": { + "message": "Ktokoľvek s heslom od vás" + }, "location": { "message": "Poloha" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefón" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Stiahnuť prílohu" + }, "downloadBitwarden": { "message": "Stiahnuť Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Zmeniť veľkosť bočnej navigácie" + }, + "whoCanView": { + "message": "Kto môže zobraziť" + }, + "specificPeople": { + "message": "Konkrétne osoby" + }, + "emailVerificationDesc": { + "message": "Po zdieľaní tohto odkazu na Send budú musieť jednotlivci overiť svoju e-mailovú adresu pomocou kódu na zobrazenie tohto Sendu." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadajte viacero e-mailových adries oddelených čiarkou." + }, + "emailPlaceholder": { + "message": "pouzivate@bitwarden.com, pouzivatel@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 23d0312caae..435aa10a360 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -4947,7 +4956,7 @@ "message": "Organization is deactivated" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "selfOwnershipLabel": { "message": "You", @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index d3b5e961ef3..5340197f8b1 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Имејл" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Преузети Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index ca5984b672e..c0c4ac3a066 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -29,7 +29,7 @@ "message": "Logga in med nyckel" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Lås upp med lösennyckel" }, "useSingleSignOn": { "message": "Använd Single Sign-On" @@ -990,6 +990,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Vem som helst med länken" + }, + "anyOneWithPassword": { + "message": "Alla som har ett lösenord inställt av dig" + }, "location": { "message": "Plats" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -3371,10 +3380,10 @@ "message": "Fel" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Det gick inte att låsa upp med lösennyckel. Försök igen eller använd en annan upplåsningsmetod." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Inga PRF-aktiverade lösennycklar finns tillgängliga för upplåsning. Logga in med en lösennyckel först." }, "decryptionError": { "message": "Dekrypteringsfel" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ladda ned bilaga" + }, "downloadBitwarden": { "message": "Ladda ner Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Ändra storlek på sidnavigering" + }, + "whoCanView": { + "message": "Vem kan se" + }, + "specificPeople": { + "message": "Specifika personer" + }, + "emailVerificationDesc": { + "message": "Efter att ha delat denna Send-länk kommer individer att behöva verifiera sin e-post med en kod för att visa denna Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Ange flera e-postadresser genom att separera dem med kommatecken." + }, + "emailPlaceholder": { + "message": "användare@bitwarden.com , användare@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index 44a284db9c6..640fdc4893b 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -990,6 +990,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இருப்பிடம்" }, @@ -2048,6 +2054,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Bitwarden-ஐப் பதிவிறக்கு" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index c15ab367666..4c36a852f6a 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -990,6 +990,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Download Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index d41ae49904d..737f379f7c3 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -990,6 +990,12 @@ "no": { "message": "ไม่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "ตำแหน่งที่ตั้ง" }, @@ -2048,6 +2054,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "ดาวน์โหลด Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Resize side navigation" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 83461d1a8a0..838d2b4944c 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -29,7 +29,7 @@ "message": "Geçiş anahtarıyla giriş yap" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Kilidi geçiş anahtarıyla aç" }, "useSingleSignOn": { "message": "Çoklu oturum açma kullan" @@ -990,6 +990,12 @@ "no": { "message": "Hayır" }, + "noAuth": { + "message": "Bağlantıya sahip olan herkes" + }, + "anyOneWithPassword": { + "message": "Belirlediğiniz parolaya sahip olan herkes" + }, "location": { "message": "Konum" }, @@ -2048,6 +2054,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -3371,10 +3380,10 @@ "message": "Hata" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Kilit geçiş anahtarıyla açılamadı. Lütfen yeniden deneyin veya başka bir kilit açma yöntemi kullanın." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Kilit açma için PRF uyumlu bir geçiş anahtarı bulunamadı. Lütfen önce bir geçiş anahtarıyla giriş yapın." }, "decryptionError": { "message": "Şifre çözme sorunu" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Ek dosyayı indir" + }, "downloadBitwarden": { "message": "Bitwarden’ı indirin" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Kenar menüsünü yeniden boyutlandır" + }, + "whoCanView": { + "message": "Kim görebilir" + }, + "specificPeople": { + "message": "Belirli kişiler" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "E-posta adreslerini virgülle ayırarak yazın." + }, + "emailPlaceholder": { + "message": "kullanici@bitwarden.com , kullanici@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index fdbd2508c44..4fded2eb53f 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -29,7 +29,7 @@ "message": "Увійти з ключем доступу" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Розблокувати з ключем доступу" }, "useSingleSignOn": { "message": "Використати єдиний вхід" @@ -990,6 +990,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Будь-хто з посиланням" + }, + "anyOneWithPassword": { + "message": "Будь-хто зі встановленим вами паролем" + }, "location": { "message": "Розташування" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Е-пошти" + }, "phone": { "message": "Телефон" }, @@ -3371,10 +3380,10 @@ "message": "Помилка" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Не вдалося розблокувати за допомогою ключа доступу. Повторіть спробу або скористайтеся іншим способом розблокування." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "Немає ключів доступу з підтримкою PRF, доступних для розблокування. Спочатку увійдіть з ключем доступу." }, "decryptionError": { "message": "Помилка розшифрування" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Завантажити вкладення" + }, "downloadBitwarden": { "message": "Завантажити Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Змінити розмір бічної панелі" + }, + "whoCanView": { + "message": "Хто може переглядати" + }, + "specificPeople": { + "message": "Певні люди" + }, + "emailVerificationDesc": { + "message": "Після того, як ви поділитеся цим посиланням на відправлення, особам необхідно буде підтвердити свої е-пошти за допомогою коду, щоб переглянути це відправлення." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введіть декілька адрес е-пошти, розділяючи їх комою." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index fdac572e550..ca6680c7d6f 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -990,6 +990,12 @@ "no": { "message": "Không" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Vị trí" }, @@ -2048,6 +2054,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Số điện thoại" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "Tải xuống Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "Thay đổi kích thước thanh bên" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index a4dee24b56a..7ffb2c444e6 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -29,7 +29,7 @@ "message": "使用通行密钥登录" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "使用通行密钥解锁" }, "useSingleSignOn": { "message": "使用单点登录" @@ -990,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "位置" }, @@ -1846,7 +1852,7 @@ "message": "网页加载时如果检测到登录表单,则执行自动填充。" }, "experimentalFeature": { - "message": "不完整或不信任的网站可以利用页面加载时的自动填充功能。" + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "进一步了解风险" @@ -2048,6 +2054,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "电话" }, @@ -2243,7 +2252,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "baseDomain": { "message": "基础域名", @@ -3054,11 +3063,11 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHoursSingle": { - "message": "在接下来的 1 小时内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 1 小时内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHours": { - "message": "在接下来的 $HOURS$ 小时内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 $HOURS$ 小时内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "hours": { @@ -3068,11 +3077,11 @@ } }, "sendExpiresInDaysSingle": { - "message": "在接下来的 1 天内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 1 天内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInDays": { - "message": "在接下来的 $DAYS$ 天内,任何人都可以通过链接访问此 Send。", + "message": "在接下来的 $DAYS$ 天内,拥有此链接的任何人都可以访问此 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "days": { @@ -3371,10 +3380,10 @@ "message": "错误" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first." + "message": "没有可用于解锁的 PRF 通行密钥。请先使用通行密钥登录。" }, "decryptionError": { "message": "解密错误" @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "下载附件" + }, "downloadBitwarden": { "message": "下载 Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "调整侧边导航栏大小" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 540a4b053ff..452a04fc091 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -990,6 +990,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "位置" }, @@ -2048,6 +2054,9 @@ "email": { "message": "電子郵件" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話號碼" }, @@ -5001,6 +5010,9 @@ } } }, + "downloadAttachmentLabel": { + "message": "Download Attachment" + }, "downloadBitwarden": { "message": "下載 Bitwarden" }, @@ -6117,5 +6129,20 @@ }, "resizeSideNavigation": { "message": "調整側邊欄大小" + }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" } -} +} \ No newline at end of file diff --git a/apps/browser/store/locales/ru/copy.resx b/apps/browser/store/locales/ru/copy.resx index 0da1e70f897..3337942f2af 100644 --- a/apps/browser/store/locales/ru/copy.resx +++ b/apps/browser/store/locales/ru/copy.resx @@ -127,44 +127,44 @@ Признан лучшим менеджером паролей по версии PCMag, WIRED, The Verge, CNET, G2 и других! ЗАЩИТИТЕ СВОЮ ЦИФРОВУЮ ЖИЗНЬ -Защитите свою цифровую жизнь и защитите её от утечек данных, создавая уникальные, надёжные пароли для каждой учетной записи. Сохраните всё в зашифрованном сквозным шифрованием хранилище паролей, доступ к которому есть только у вас. +Обезопасьте свою цифровую жизнь, защитив её от утечек данных с помощью уникальных, надёжных паролей для каждой учётной записи. Сохраните всё в зашифрованном сквозным шифрованием хранилище паролей, доступ к которому есть только у вас. ДОСТУП К СВОИМ ДАННЫМ В ЛЮБОМ МЕСТЕ, В ЛЮБОЕ ВРЕМЯ, НА ЛЮБОМ УСТРОЙСТВЕ -Легко управляйте, храните, защищайте и делитесь неограниченным количеством паролей на неограниченном количестве устройств без ограничений. +Без труда управляйте паролями, храните их, защищайте и делитесь ими в неограниченном количестве на любом числе устройств. -КАЖДЫЙ ДОЛЖЕН ИМЕТЬ ИНСТРУМЕНТЫ ДЛЯ БЕЗОПАСНОСТИ В СЕТИ -Используйте Bitwarden бесплатно без рекламы или продажи данных. Bitwarden считает, что каждый должен иметь возможность оставаться в безопасности в сети. Премиум-планы предлагают доступ к расширенным функциям. +У КАЖДОГО ДОЛЖНЫ БЫТЬ СРЕДСТВА БЕЗОПАСНОСТИ В СЕТИ +Используйте Bitwarden бесплатно без рекламы и не опасаясь, что ваши данные будут проданы. Компания Bitwarden считает, что у каждого должна быть возможность оставаться в безопасности в сети. Если же нужно больше функций, то обратите внимание на Премиум-планы. РАСШИРЯЙТЕ ВОЗМОЖНОСТИ СВОИХ КОМАНД С ПОМОЩЬЮ BITWARDEN -Планы для Teams и Enterprise включают профессиональные бизнес-функции. Вот несколько примеров: интеграция SSO, собственный хостинг, интеграция каталогов и SCIM, глобальные политики, доступ через API, журналы событий и многое другое. +Планы Teams и Enterprise включают профессиональные бизнес-функции. Среди них интеграция SSO, собственный хостинг, интеграция каталогов и SCIM, глобальные политики, доступ через API, журналы событий и многое другое. Используйте Bitwarden для защиты своих сотрудников и обмена конфиденциальной информацией с коллегами. Дополнительные причины выбрать Bitwarden: -Шифрование мирового класса -Пароли защищены усовершенствованным сквозным шифрованием (AES-256, использование salt и PBKDF2 SHA-256), поэтому ваши данные остаются в безопасности и конфиденциальности. +Шифрование мирового уровня +Пароли защищены усовершенствованным сквозным шифрованием (AES-256, хеширование с солью, PBKDF2 SHA-256), поэтому ваши конфиденциальные данные надёжно защищены. Сторонние аудиты -Bitwarden регулярно проводит комплексные сторонние аудиты безопасности с известными фирмами по безопасности. Эти ежегодные аудиты включают оценку исходного кода и тестирование на проникновение по IP-адресам Bitwarden, серверам и веб-приложениям. +Bitwarden регулярно проводит комплексные сторонние аудиты с известными фирмами по безопасности. Эти ежегодные аудиты включают оценку исходного кода и тестирование на проникновение по IP-адресам Bitwarden, серверам и веб-приложениям. -Расширенная 2FA -Защитите свой вход с помощью стороннего аутентификатора, кодов, отправленных по электронной почте, или учетных данных FIDO2 WebAuthn, таких как аппаратный ключ безопасности или ключ доступа. +Расширенная двухфакторная аутентификация (2FA) +Вход может быть защищён с помощью стороннего аутентификатора, кодов по электронной почте или учётных данных FIDO2 WebAuthn, таких как аппаратный ключ безопасности или ключ доступа. Bitwarden Send Передавайте данные другим без посредников, сохраняя сквозное шифрование и ограничивая раскрытие информации. Встроенный генератор паролей -Создавайте длинные, сложные и уникальные пароли и имена пользователей для каждого посещаемого вами сайта. Интегрируйтесь с поставщиками псевдонимов электронной почты для дополнительной конфиденциальности. +Создавайте длинные, сложные и уникальные пароли и логины для каждого посещаемого сайта. Интегрируйтесь с поставщиками псевдонимов электронной почты для дополнительной конфиденциальности. Многоязычный перевод -Bitwarden переведён на более чем 60 языков, с помощью мирового сообщества через Crowdin. +Bitwarden переведён на более чем 60 языков с помощью мирового сообщества через Crowdin. Кроссплатформенные приложения -Защищайте и делитесь конфиденциальными данными в вашем хранилище Bitwarden из любого браузера, мобильного устройства или настольной ОС и т. д. +Защищайте и делитесь конфиденциальными данными в вашем хранилище Bitwarden из любого браузера, мобильного устройства, настольной ОС и др. Bitwarden защищает не только пароли -Решения со сквозным шифрованием для управления учётными данными от Bitwarden позволяют организациям защищать всё, включая секреты разработчиков и ключи доступа. Посетите Bitwarden.com, чтобы узнать больше о Bitwarden Secrets Manager и Bitwarden Passwordless.dev! +Решения со сквозным шифрованием для управления учётными данными от Bitwarden позволяют организациям защищать всё, включая секреты разработчиков и ключи доступа. Посетите сайт Bitwarden.com, чтобы узнать больше о Bitwarden Secrets Manager и Bitwarden Passwordless.dev! From 52e416e85efbc7f4a4375b163223c529b0162290 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:58:03 +0000 Subject: [PATCH 092/130] Autosync the updated translations (#18667) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/af/messages.json | 83 +++++++++-- apps/web/src/locales/ar/messages.json | 83 +++++++++-- apps/web/src/locales/az/messages.json | 179 +++++++++++++++-------- apps/web/src/locales/be/messages.json | 83 +++++++++-- apps/web/src/locales/bg/messages.json | 81 ++++++++-- apps/web/src/locales/bn/messages.json | 83 +++++++++-- apps/web/src/locales/bs/messages.json | 83 +++++++++-- apps/web/src/locales/ca/messages.json | 83 +++++++++-- apps/web/src/locales/cs/messages.json | 75 +++++++++- apps/web/src/locales/cy/messages.json | 83 +++++++++-- apps/web/src/locales/da/messages.json | 83 +++++++++-- apps/web/src/locales/de/messages.json | 85 +++++++++-- apps/web/src/locales/el/messages.json | 83 +++++++++-- apps/web/src/locales/en_GB/messages.json | 83 +++++++++-- apps/web/src/locales/en_IN/messages.json | 83 +++++++++-- apps/web/src/locales/eo/messages.json | 83 +++++++++-- apps/web/src/locales/es/messages.json | 83 +++++++++-- apps/web/src/locales/et/messages.json | 83 +++++++++-- apps/web/src/locales/eu/messages.json | 83 +++++++++-- apps/web/src/locales/fa/messages.json | 83 +++++++++-- apps/web/src/locales/fi/messages.json | 83 +++++++++-- apps/web/src/locales/fil/messages.json | 83 +++++++++-- apps/web/src/locales/fr/messages.json | 89 +++++++++-- apps/web/src/locales/gl/messages.json | 83 +++++++++-- apps/web/src/locales/he/messages.json | 83 +++++++++-- apps/web/src/locales/hi/messages.json | 83 +++++++++-- apps/web/src/locales/hr/messages.json | 113 ++++++++++---- apps/web/src/locales/hu/messages.json | 75 +++++++++- apps/web/src/locales/id/messages.json | 83 +++++++++-- apps/web/src/locales/it/messages.json | 93 ++++++++++-- apps/web/src/locales/ja/messages.json | 83 +++++++++-- apps/web/src/locales/ka/messages.json | 83 +++++++++-- apps/web/src/locales/km/messages.json | 83 +++++++++-- apps/web/src/locales/kn/messages.json | 83 +++++++++-- apps/web/src/locales/ko/messages.json | 83 +++++++++-- apps/web/src/locales/lv/messages.json | 81 ++++++++-- apps/web/src/locales/ml/messages.json | 83 +++++++++-- apps/web/src/locales/mr/messages.json | 83 +++++++++-- apps/web/src/locales/my/messages.json | 83 +++++++++-- apps/web/src/locales/nb/messages.json | 83 +++++++++-- apps/web/src/locales/ne/messages.json | 83 +++++++++-- apps/web/src/locales/nl/messages.json | 89 +++++++++-- apps/web/src/locales/nn/messages.json | 83 +++++++++-- apps/web/src/locales/or/messages.json | 83 +++++++++-- apps/web/src/locales/pl/messages.json | 83 +++++++++-- apps/web/src/locales/pt_BR/messages.json | 89 +++++++++-- apps/web/src/locales/pt_PT/messages.json | 81 ++++++++-- apps/web/src/locales/ro/messages.json | 83 +++++++++-- apps/web/src/locales/ru/messages.json | 81 ++++++++-- apps/web/src/locales/si/messages.json | 83 +++++++++-- apps/web/src/locales/sk/messages.json | 83 +++++++++-- apps/web/src/locales/sl/messages.json | 123 ++++++++++++---- apps/web/src/locales/sr_CS/messages.json | 83 +++++++++-- apps/web/src/locales/sr_CY/messages.json | 83 +++++++++-- apps/web/src/locales/sv/messages.json | 87 +++++++++-- apps/web/src/locales/ta/messages.json | 83 +++++++++-- apps/web/src/locales/te/messages.json | 83 +++++++++-- apps/web/src/locales/th/messages.json | 83 +++++++++-- apps/web/src/locales/tr/messages.json | 81 ++++++++-- apps/web/src/locales/uk/messages.json | 83 +++++++++-- apps/web/src/locales/vi/messages.json | 83 +++++++++-- apps/web/src/locales/zh_CN/messages.json | 109 +++++++++++--- apps/web/src/locales/zh_TW/messages.json | 147 +++++++++++++------ 63 files changed, 4731 insertions(+), 762 deletions(-) diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 9ffb2bb3ffb..004742342c5 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-pos" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Een of meer organisasiebeleide verhoed u om u persoonlike kluis uit te stuur." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Kies SSO-tipe" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index b54808089cd..a14d85c1bdf 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "لا توجد تطبيقات حرجة في خطر" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "الوصول إلى الذكاء" }, @@ -250,6 +268,9 @@ "application": { "message": "تطبيق" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "كلمات المرور المعرضة للخطر" }, @@ -586,6 +607,9 @@ "email": { "message": "البريد الإلكتروني" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "الهاتف" }, @@ -1365,6 +1389,12 @@ "no": { "message": "لا" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "الموقع" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index c272c48e2af..ed133481a99 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -1,33 +1,51 @@ { "allApplications": { - "message": "Bütün tətbiqlər" + "message": "Bütün proqramlar" }, "activity": { "message": "Fəaliyyət" }, "appLogoLabel": { - "message": "Bitwarden loqosu" + "message": "Açar alətləri" }, "criticalApplications": { - "message": "Kritik tətbiqlər" + "message": "Kritik proqramlar" }, "noCriticalAppsAtRisk": { "message": "Risk altında heç bir kritik tətbiq yoxdur" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { - "message": "Access Intelligence" + "message": "Giriş məlumatları" }, "passwordRisk": { - "message": "Parol riski" + "message": "Açar təhlükəsi" }, "noEditPermissions": { - "message": "Bu elementə düzəliş etmə icazəniz yoxdur" + "message": "Bu bəndə düzəliş etmə icazəniz yoxdur" }, "reviewAtRiskPasswords": { - "message": "Tətbiqlər arasında riskli (zəif, ifşa olunmuş və ya təkrar istifadə olunmuş) parolları incələyin. İstifadəçilərinizin riskli parollara yönəlmiş təhlükəsizlik tədbirlərinə əhəmiyyət vermələri üçün kritik tətbiqlərinizi seçin." + "message": "Proqramlar arasında risk altında olan açarları (zəif, açıq və ya təkrar istifadə olunmuş) nəzərdən keçirin. İstehlakçılarınızın risk altında olan açarları həll etməsinə yönəlmiş təhlükəsizlik tədbirlərinə üstünlük vermək üçün ən vacib proqramlarınızı seçin." }, "reviewAtRiskLoginsPrompt": { - "message": "Riskli girişləri incələ" + "message": "Risk altında olan girişi nəzərdən keçirin" }, "dataLastUpdated": { "message": "Verilərin son güncəlləmə tarixi: $DATE$", @@ -39,10 +57,10 @@ } }, "noReportRan": { - "message": "Hələ heç bir hesabat yaratmamısınız" + "message": "Hələ heç bir hesabat qurmamısınız" }, "notifiedMembers": { - "message": "Məlumatlandırılan üzvlər" + "message": "Bildirilən üzvlər" }, "revokeMembers": { "message": "Üzvləri ləğv et" @@ -51,10 +69,10 @@ "message": "Üzvləri bərpa et" }, "cannotRestoreAccessError": { - "message": "Təşkilat erişimi bərpa edilə bilmir" + "message": "Təşkilata giriş bərpa edilə bilmir" }, "allApplicationsWithCount": { - "message": "Bütün tətbiqlər ($COUNT$)", + "message": "Bütün proqramlar ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -63,7 +81,7 @@ } }, "createNewLoginItem": { - "message": "Yeni giriş elementi yarat" + "message": "Yeni giriş elementi meydana gətirin" }, "percentageCompleted": { "message": "$PERCENT$% tamamlandı", @@ -88,28 +106,28 @@ } }, "passwordChangeProgress": { - "message": "Parol dəyişmə irəliləyişi" + "message": "Açar dəyişikliyi prosesi" }, "assignMembersTasksToMonitorProgress": { - "message": "İrəliləyişi izləmək üçün üzvlərə tapşırıqlar təyin edin" + "message": "Prosesi izləmək üçün üzvlərə tapşırıqlar təyin edin" }, "onceYouReviewApplications": { - "message": "Tətbiqləri incələyib kritik olaraq işarələdikdən sonra, üzvlərə parollarını dəyişməsi üçün tapşırıqlar təyin edin." + "message": "Proqramları nəzərdən keçirib kritik olaraq qeyd etdikdən sonra, üzvlərə açarları dəyişməsi üçün tapşırıqlar təyin edin." }, "sendReminders": { - "message": "Xatırlatma göndər" + "message": "Yaddaqalan qeydləri göndər" }, "onceYouMarkApplicationsCriticalTheyWillDisplayHere": { - "message": "Tətbiqləri kritik olaraq işarələsəniz, onlar burada nümayiş olunacaq." + "message": "Proqramları kritik olaraq qeyd etsəniz, onlar burada nümayiş olunacaq." }, "viewAtRiskMembers": { - "message": "Riskli üzvlərə bax" + "message": "Təhlükəli üzvlərə bax" }, "viewAtRiskApplications": { - "message": "Riskli tətbiqlərə bax" + "message": "Təhlükəli proqramlara bax" }, "criticalApplicationsAreAtRisk": { - "message": "$COUNT$/$TOTAL$ kritik tətbiq, riskli parollara görə risk altındadır", + "message": "$COUNT$/$TOTAL$ kritik tətbiq, təhlükəli şifrələrə görə risk altındadır", "placeholders": { "count": { "content": "$1", @@ -122,7 +140,7 @@ } }, "criticalApplicationsWithCount": { - "message": "Kritik tətbiqlər ($COUNT$)", + "message": "Kritik proqramlar ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -131,7 +149,7 @@ } }, "criticalApplicationsMarked": { - "message": "kritik olaraq işarələnmiş tətbiqlər" + "message": "kritik olaraq qeyd edilmiş proqramlar" }, "countOfCriticalApplications": { "message": "$COUNT$ kritik tətbiq", @@ -152,7 +170,7 @@ } }, "countOfAtRiskPasswords": { - "message": "$COUNT$ parol risk altındadır", + "message": "$COUNT$ Açar risk altındadır", "placeholders": { "count": { "content": "$1", @@ -161,7 +179,7 @@ } }, "newPasswordsAtRisk": { - "message": "$COUNT$ yeni parol risklidir", + "message": "$COUNT$ yeni açar təhlükə altındadır", "placeholders": { "count": { "content": "$1", @@ -170,7 +188,7 @@ } }, "notifiedMembersWithCount": { - "message": "Məlumatlandırılan üzvlər ($COUNT$)", + "message": "Bildirilən üzvlər ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -179,19 +197,19 @@ } }, "noDataInOrgTitle": { - "message": "Heç bir veri tapılmadı" + "message": "Heç bir məlumat aşkar olunmadı" }, "noDataInOrgDescription": { - "message": "Access Intelligence-i istifadə etməyə başlamaq üçün təşkilatınızın giriş verilərini daxilə köçürün. Bunu etdikdən sonra, bunları edə biləcəksiniz:" + "message": "Giriş məlumatı ilə başlamaq üçün müəssisənizin giriş məlumatlarını idxal edin. Bunu etdikdən sonra, bunları edə biləcəksiniz:" }, "feature1Title": { - "message": "Tətbiqləri kritik olaraq işarələmə" + "message": "Proqramları kritik kimi qeyd edin" }, "feature1Description": { - "message": "Bu, əvvəlcə ən vacib tətbiqlərinizdəki riskləri xaric etməyinizə kömək edəcək." + "message": "Bu, əvvəlcə ən vacib proqramlarınızdakı riskləri aradan qaldırmağa kömək edəcək." }, "feature2Title": { - "message": "Üzvlərin təhlükəsizliyini təkmilləşdirməsinə kömək" + "message": "Üzvlərə təhlükəsizliklərini yaxşılaşdırmağa kömək edin" }, "feature2Description": { "message": "Riskli üzvlərə kimlik məlumatlarını güncəlləməsi üçün təhlükəsizlik tapşırıqları təyin edin." @@ -221,7 +239,7 @@ "message": "Tətbiqi kritik olaraq işarələ" }, "markAsCritical": { - "message": "Kritik olaraq işarələ" + "message": "Kritik olaraq qeyd et" }, "applicationsSelected": { "message": "tətbiq seçildi" @@ -250,6 +268,9 @@ "application": { "message": "Tətbiq" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Riskli parollar" }, @@ -586,6 +607,9 @@ "email": { "message": "E-poçt" }, + "emails": { + "message": "E-poçtlar" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Xeyr" }, + "noAuth": { + "message": "Keçidə sahib olan hər kəs" + }, + "anyOneWithPassword": { + "message": "Sizin təyin etdiyiniz parola sahib hər kəs" + }, "location": { "message": "Yerləşmə" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Növbəti ödəniş" }, + "nextChargeDate": { + "message": "Növbəti ödəniş vaxtı" + }, "plan": { "message": "Plan" }, @@ -5419,10 +5452,10 @@ "message": "Seçiləni bərpa et" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arxivlənmiş element bərpa edildi" }, "archivedItemsRestored": { - "message": "Archived items restored" + "message": "Arxivlənmiş elementlər bərpa edildi" }, "restoredItem": { "message": "Element bərpa edildi" @@ -5916,35 +5949,35 @@ } }, "centralizeDataOwnership": { - "message": "Centralize organization ownership" + "message": "Təşkilat sahibliyini mərkəzləşdirin" }, "centralizeDataOwnershipDesc": { - "message": "All member items will be owned and managed by the organization. Admins and owners are exempt. " + "message": "Bütün üzv elementləri, təşkilat tərəfindən sahiblənəcək və idarə ediləcək. Adminlər və sahibləri daxil deyil. " }, "centralizeDataOwnershipContentAnchor": { - "message": "Learn more about centralized ownership", + "message": "Mərkəzi sahiblik barədə daha ətraflı", "description": "This will be used as a hyperlink" }, "benefits": { - "message": "Benefits" + "message": "Faydaları" }, "centralizeDataOwnershipBenefit1": { - "message": "Gain full visibility into credential health, including shared and unshared items." + "message": "Paylaşılan və paylaşılmayan elementlər daxil olmaqla kimlik məlumatı sağlamlığına tam görünmə əldə edin." }, "centralizeDataOwnershipBenefit2": { - "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + "message": "Üzvlərin tərk etməsi və təhvil zamanı elementləri asanlıqla köçürün, erişim zamanı heç bir boşluğa yer buraxmayın." }, "centralizeDataOwnershipBenefit3": { - "message": "Give all users a dedicated \"My Items\" space for managing their own logins." + "message": "Bütün istifadəçilərə öz giriş məlumatlarını idarə edə biləcəkləri \"Elementlərim\" sahəsi verin." }, "centralizeDataOwnershipWarningTitle": { - "message": "Prompt members to transfer their items" + "message": "Üzvlərdən öz elementlərini köçürməsi soruşulsun" }, "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": "Üzvlərin fərdi seyflərində elementləri varsa, onlardan elementləri təşkilata köçürməsi, ya da tərk etməsi soruşulacaq. Əgər tərk etsələr, erişimləri ləğv ediləcək, ancaq istənilən vaxt bərpa edə biləcəklər." }, "centralizeDataOwnershipWarningLink": { - "message": "Learn more about the transfer" + "message": "Köçürmə barədə daha ətraflı" }, "organizationDataOwnership": { "message": "Təşkilata veri üzərində məcburi sahiblik ver" @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Bir və ya daha çox təşkilat siyasəti, fərdi seyfi xaricə köçürməyinizi əngəlləyir." }, - "activateAutofill": { - "message": "Avto-doldurmanı aktivləşdir" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Bütün mövcud və yeni üzvlər üçün brauzer uzantısında səhifə yüklənəndə avto-doldurmanı ayarını aktivləşdirin." + "activateAutofillPolicyDescription": { + "message": "Bütün mövcud və yeni üzvlər üçün brauzer uzantısında \"Səhifə yüklənəndə avto-doldur\" ayarını aktivləşdirin." }, - "experimentalFeature": { - "message": "Təhlükəli və ya güvənilməyən veb saytlar, səhifə yüklənərkən avto-doldurmanı istifadə edə bilər." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Avto-doldurma haqqında daha ətraflı" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO növü seçin" @@ -10433,7 +10466,7 @@ "message": "Seyf event verilərini Datadog serverinizə göndərin" }, "huntressEventIntegrationDesc": { - "message": "Send event data to your Huntress SIEM instance" + "message": "Event verilərini öz \"Huntress SIEM instance\"na göndər" }, "failedToSaveIntegration": { "message": "İnteqrasiya saxlanılmadı. Lütfən daha sonra yenidən sınayın." @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden, ilk 72 saat ərzində domeni 3 dəfə götürməyə çalışacaq. Əgər domen götürülə bilməsə, \"host\"unuzdakı DNS qeydini yoxlayın və manual götürün. Domen götürülməsə, 7 gün ərzində təşkilatınızdan silinəcək." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden, domeni 72 saat ərzində götürməyə çalışacaq. Domen götürülə bilmirsə, DNS qeydinizi doğrulayın və manual götürün. Götürülməmiş domenlər 7 gün sonra xaric edilir." + }, + "automaticDomainClaimProcess2": { + "message": "Götürüldükdən sonra, domeni götürmüş mövcud üzvlərə e-poçtla məlumat veriləcək " + }, + "accountOwnershipChange": { + "message": "hesab sahibliyinin dəyişməsi" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ götürülmədi. DNS qeydlərinizi yoxlayın.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Götürüldü" }, - "domainStatusUnderVerification": { - "message": "Doğrulama altında" + "domainStatusPending": { + "message": "Gözlənilir" }, "claimedDomainsDescription": { "message": "Üzv hesablarına sahiblik etmək üçün bir domen götürün. Domen götürmüş üzvlər üçün giriş zamanı SSO identifikatoru səhifəsi ötürüləcək və inzibatçılar bu domenə aid hesabları silə biləcək." @@ -12102,13 +12147,13 @@ "message": "İndi doğrula." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Kilidi keçid açarı ilə aç" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Kilid keçid açarı ilə açılmadı. Lütfən yenidən sınayın, ya da başqa kilid açma üsulunu sınayın." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Kilidi açmaq üçün PRF dəstəkli keçid açarı yoxdur." }, "additionalStorageGB": { "message": "Əlavə anbar sahəsi GB" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Bütün $GB$ GB-lıq şifrələnmiş anbar sahənizi istifadə etmisiniz. Faylları saxlaya bilmək üçün daha çox anbar sahəsi əlavə edin." }, + "whoCanView": { + "message": "Kimlər baxa bilər" + }, + "specificPeople": { + "message": "Xüsusi insanlar" + }, + "emailVerificationDesc": { + "message": "Bu Send keçidini paylaşdıqdan sonra, bu \"Send\"ə baxması üçün insanlar e-poçtlarını bir kodla doğrulamalıdırlar." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Birdən çox e-poçtu daxil edərkən vergül istifadə edin." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Anbar sahəsini xaric etdiyiniz zaman, növbəti hesabınıza avtomatik olaraq köçürüləcək mütənasib hesab krediti alacaqsınız." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-poçt qorunur" + }, + "invalidSendPassword": { + "message": "Yararsız Send parolu" } } diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index aa5d985a0c1..3f339dbbb2e 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Кіраванне доступам" }, @@ -250,6 +268,9 @@ "application": { "message": "Праграма" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Паролі ў зоне рызыкі" }, @@ -586,6 +607,9 @@ "email": { "message": "Электронная пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Тэлефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Адна або больш палітык арганізацыі не дазваляюць вам экспартаваць асабістае сховішча." }, - "activateAutofill": { - "message": "Актываваць аўтазапаўненне" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Актываваць аўтазапаўненне падчас загрузкі старонкі ў наладах пашырэння браўзера для ўсіх існуючых і новых удзельнікаў." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Скампраметаваныя або ненадзейныя вэб-сайты могуць задзейнічаць функцыю аўтазапаўнення падчас загрузкі старонкі." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Даведацца больш пра аўтазапаўненне" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Выберыце тып SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 0d1d9b2527b..a180f31de62 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Няма важни приложения в риск" }, + "critical": { + "message": "Критични ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Некритични ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Анализ на достъпа" }, @@ -250,6 +268,9 @@ "application": { "message": "Приложение" }, + "applications": { + "message": "Приложения" + }, "atRiskPasswords": { "message": "Пароли в риск" }, @@ -586,6 +607,9 @@ "email": { "message": "Електронна поща" }, + "emails": { + "message": "Е-пощи" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Всеки с връзката" + }, + "anyOneWithPassword": { + "message": "Всеки с парола, зададена от Вас" + }, "location": { "message": "Местоположение" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Следващо плащане" }, + "nextChargeDate": { + "message": "Следваща дата за таксуване" + }, "plan": { "message": "План" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Една или повече от настройките на организацията Ви не позволяват да изнасяте личния си трезор." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Включване на автоматичното попълване" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Включване на автоматичното попълване при зареждане на страница в браузърното разширение на всички текущи и бъдещи членове." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Компроментирани и измамни уеб сайтове могат да се възползват от автоматичното попълване при зареждане на страницата." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Научете повече относно автоматичното попълване" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Битуорден ще се опита да присвои домейна 3 пъти през първите 72 часа. Ако той не може да бъде присвоен, проверете записа за DNS в сървъра си и направете присвояването ръчно. Домейнът ще бъде премахнат от организацията Ви след 7 дни, ако не бъде присвоен." }, + "automaticDomainClaimProcess1": { + "message": "Битуорден ще се опита да присвои домейна в рамките на 72 часа. Ако той не може да бъде присвоен, проверете записа си в DNS и направете присвояването ръчно. Неприсвоените домейни се премахват след 7 дни." + }, + "automaticDomainClaimProcess2": { + "message": "След присвояването, текущите членове с присвоени домейни ще получат е-писмо относно " + }, + "accountOwnershipChange": { + "message": "промяната на собствеността на акаунта" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "Домейнът $DOMAIN$ не е присвоен. Проверете записите в DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Присвоен" }, - "domainStatusUnderVerification": { - "message": "В процес на проверка" + "domainStatusPending": { + "message": "На изчакване" }, "claimedDomainsDescription": { "message": "Присвойте домейн, за да притежавате акаунтите на членовете. Страницата за еднократно удостоверяване ще бъде пропускана при вписването на членове с присвоени домейни, а администраторите ще могат да изтриват присвоените акаунти." @@ -12102,13 +12147,13 @@ "message": "Потвърдете сега." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Отключване със секретен ключ" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Няма секретни ключове с включено PRF, налични за отключване." }, "additionalStorageGB": { "message": "Допълнително място в ГБ" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Използвали сте всичките си $GB$ GB от наличното си място за съхранение на шифровани данни. Ако искате да продължите да добавяте файлове, добавете повече място за съхранение." }, + "whoCanView": { + "message": "Кой може да преглежда" + }, + "specificPeople": { + "message": "Определени хора" + }, + "emailVerificationDesc": { + "message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Можете да въведете повече е-пощи, като ги разделите със запетая." + }, + "emailPlaceholder": { + "message": "потребител@bitwarden.com , потребител@acme.com" + }, "whenYouRemoveStorage": { "message": "Когато премахнете съхранението, ще получите пропорционално задължение към акаунта си, което ще бъде включено автоматично в следващата Ви сметка." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Е-пощата е защитена" + }, + "invalidSendPassword": { + "message": "Неправилна парола за Изпращане" } } diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index efed3069132..415284c3a5f 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ই-মেইল" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ফোন" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 1d4010331d8..cd3e8db2d58 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Imejl" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index c28b0bb4f35..f0b3dc380a5 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Intel·ligència d'accés" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicació" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Correu electrònic" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telèfon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o més polítiques d'organització us impedeixen exportar la vostra caixa forta." }, - "activateAutofill": { - "message": "Activa l'emplenament automàtic" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activeu l'emplenament automàtic a la configuració de la càrrega de la pàgina en l'extensió del navegador per a tots els membres existents i nous." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Els llocs web compromesos o no fiables poden aprofitar l'emplenament automàtic en carregar de la pàgina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Més informació sobre l'emplenament automàtic" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecciona el tipus d'SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 3f85b1641f2..6f862bb4c51 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Žádné ohrožené kritické aplikace" }, + "critical": { + "message": "Kritické ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nekritické ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Přístup k inteligenci" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikace" }, + "applications": { + "message": "Aplikace" + }, "atRiskPasswords": { "message": "Ohrožená hesla" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-maily" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Kdokoli s odkazem" + }, + "anyOneWithPassword": { + "message": "Kdokoli s heslem od Vás" + }, "location": { "message": "Umístění" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Další platba" }, + "nextChargeDate": { + "message": "Datum další platby" + }, "plan": { "message": "Plán" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Jedna nebo více zásad organizace Vám brání v exportu Vašeho osobního trezoru." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Aktivovat automatické vyplnění" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Aktivuje automatické vyplnění při načítání stránky pro rozšíření prohlížeče pro všechny existující i nové členy." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Kompromitované nebo nedůvěryhodné webové stránky mohou zneužívat automatické vyplňování při načítání stránky." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Více informací o automatickém vyplňování" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden se pokusí uplatnit doménu třikrát během prvních 72 hodin. Pokud doménu nelze uplatnit, zkontrolujte záznam DNS v hostitelském počítači a uplatněte ji ručně. Pokud se doménu nepodaří uplatnit, bude z Vaší organizace odebrána do 7 dnů." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden se pokusí nárokovat doménu do 72 hodin. Pokud doménu nelze nárokovat, ověřte svůj DNS záznam a nárokujte ručně. Nenárokované domény jsou odebrány po 7 dnech." + }, + "automaticDomainClaimProcess2": { + "message": "Po nárokování budou stávající členové s nárokovanými doménami obeznámeni e-mailem o " + }, + "accountOwnershipChange": { + "message": "změně vlastnictví účtu" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nebyla uplatněna. Zkontrolujte DNS záznamy.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Uplatněno" }, - "domainStatusUnderVerification": { - "message": "V ověřování" + "domainStatusPending": { + "message": "Čekající" }, "claimedDomainsDescription": { "message": "Požádejte o doménu, abyste mohli vlastnit členské účty. Stránka s identifikátorem SSO bude při přihlašování členů s deklarovanými doménami přeskočena a správci budou moci deklarované účty smazat." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Využili jste celých $GB$ GB Vašeho šifrovaného úložiště. Chcete-li pokračovat v ukládání souborů, přidejte další úložiště." }, + "whoCanView": { + "message": "Kdo může zobrazit" + }, + "specificPeople": { + "message": "Vybraní lidé" + }, + "emailVerificationDesc": { + "message": "Po sdílení tohoto odkazu Send budou muset jednotlivci ověřit svůj e-mail pomocí kódu pro zobrazení tohoto Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Zadejte více e-mailů oddělených čárkou." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Když odeberete úložiště, obdržíte kredit, který bude automaticky převeden do Vašeho dalšího vyúčtování." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail je chráněný" + }, + "invalidSendPassword": { + "message": "Neplatné heslo k Send" } } diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index a815d4b10a8..9160351f225 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Ebost" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Ffôn" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 86c28faec3f..9e0593c546e 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Adgangsefterretning" }, @@ -250,6 +268,9 @@ "application": { "message": "Applikation" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Udsatte adgangskoder" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flere organisationspolitikker forhindrer eksport af din personlige boks." }, - "activateAutofill": { - "message": "Aktivér autoudfyldning" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivér indstillingen Autoudfyldning ved sideindlæsning i browserudvidelsen for alle eksisterende og nye medlemmer." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Kompromitterede eller ikke-betroede websteder kan udnytte autoudfyldning ved sideindlæsning." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Læs mere om autoudfyldning" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Vælg SSO-type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden vil forsøge at registrere domænet 3 gange i løbet af de første 72 timer. Kan domænet ikke registreres, tjek DNS-posten på værten og registrér manuelt. Såfremt uregistreret efter 7 dage, fjernes domænet fra organisationen." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ ikke registreret. Tjek DNS-posterne.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Registreret" }, - "domainStatusUnderVerification": { - "message": "Under verifikation" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 00af564413c..26536548d09 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Keine kritischen Anwendungen gefährdet" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Anwendung" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Gefährdete Passwörter" }, @@ -586,6 +607,9 @@ "email": { "message": "E-Mail" }, + "emails": { + "message": "E-Mails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nein" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Standort" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Nächste Abbuchung" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Tarif" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Eine oder mehrere Unternehmensrichtlinien verhindern es, dass du deinen persönlichen Tresor exportieren kannst." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Auto-Ausfüllen aktivieren" }, - "activateAutofillPolicyDesc": { - "message": "Aktiviere die Einstellung \"Auto-Ausfüllen beim Laden einer Seite\" in der Browser-Erweiterung für alle bestehenden und neuen Mitglieder." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Kompromittierte oder nicht vertrauenswürdige Websites können Auto-Ausfüllen beim Laden der Seite ausnutzen." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Erfahre mehr über Auto-Ausfüllen" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden wird in den ersten 72 Stunden 3 Mal versuchen, die Domain zu beanspruchen. Wenn die Domain nicht beansprucht werden kann, überprüfe den DNS-Eintrag auf deinem Host und beanspruche sie manuell. Die Domain wird in 7 Tagen aus deiner Organisation entfernt, wenn sie nicht beansprucht ist." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nicht beansprucht. Überprüfe deine DNS-Einträge.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Beansprucht" }, - "domainStatusUnderVerification": { - "message": "In Verifizierung" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Beanspruche eine Domain für eigene Mitgliederkonten. Die SSO-Kennungsseite wird beim Anmelden für Mitglieder mit beanspruchten Domains übersprungen und Administratoren werden in der Lage sein, beanspruchte Konten zu löschen." @@ -12102,13 +12147,13 @@ "message": "Jetzt verifizieren." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Mit Passkey entsperren" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Entsperren mit Passkey fehlgeschlagen. Bitte versuche es erneut oder verwende eine andere Entsperrmethode." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Es sind keine PRF-fähigen Passkeys zum Entsperren verfügbar." }, "additionalStorageGB": { "message": "Zusätzlicher Speicher GB" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Du hast die gesamten $GB$ GB deines verschlüsselten Speichers verwendet. Um mit dem Speichern von Dateien fortzufahren, füge mehr Speicher hinzu." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Wenn du Speicherplatz entfernst, erhältst du eine anteilige Gutschrift, die automatisch mit deiner nächsten Rechnung verrechnet wird." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-Mail-Adresse geschützt" + }, + "invalidSendPassword": { + "message": "Ungültiges Send-Passwort" } } diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 915063fa0cf..32311ab1855 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Πληροφορίες Πρόσβασης" }, @@ -250,6 +268,9 @@ "application": { "message": "Εφαρμογή" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Κωδικοί πρόσβασης σε κίνδυνο" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Τηλέφωνο" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Όχι" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Τοποθεσία" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Μία ή περισσότερες οργανωτικές πολιτικές σας αποτρέπει από την εξαγωγή του προσωπικού vault." }, - "activateAutofill": { - "message": "Ενεργοποίηση αυτόματης συμπλήρωσης" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Μάθετε περισσότερα για την αυτόματη συμπλήρωση" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Επιλογή Τύπου SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index 9132193cb87..7e389243398 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organisation policies prevent you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organisation in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 0e2585e8f13..4e9aa6adf4c 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organisation policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organisation in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 388f094918d..babe78377a3 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikaĵo" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Retpoŝto" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Loko" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "En konfirmado" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index e53e6047f35..3535a3d94fe 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No hay aplicaciones críticas en riesgo" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Inteligencia de Acceso" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicación" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Teléfono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Ubicación" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o más políticas de tu organización te impiden exportar tu caja fuerte personal." }, - "activateAutofill": { - "message": "Activar autocompletar" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Active el autocompletar con los ajustes de carga de página en la extensión del navegador para todos los miembros existentes y nuevos." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Los sitios web comprometidos o no confiables pueden explotar autocompletar al cargar la página." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Más información sobre autocompletar" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Seleccionar tipo de SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 15546143435..f094c6858e7 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoninumber" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Asukoht" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Üks või enam organisatsiooni poliitikat ei võimalda sul oma personaalset hoidlat eksportida." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 34f7010daf8..4ba28b78f39 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Emaila" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefonoa" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ez" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Erakundeko politika batek edo gehiagok kutxa gotorra esportatzea galarazten dute." }, - "activateAutofill": { - "message": "Gaitu betetze automatikoa" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Automatikoki betetzeari buruzko informazio gehiago" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Aukeratu SSO mota" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 6472c5ccc63..c2ebbf86c40 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "هیچ برنامه حیاتی در معرض خطر نیست" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "دسترسی به هوش مصنوعی" }, @@ -250,6 +268,9 @@ "application": { "message": "برنامه" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "کلمات عبور در معرض خطر" }, @@ -586,6 +607,9 @@ "email": { "message": "ایمیل" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "تلفن" }, @@ -1365,6 +1389,12 @@ "no": { "message": "خیر" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "موقعیت" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "یک یا چند خط مشی سازمان از برون ریزی گاوصندوق شخصی شما جلوگیری می‌کند." }, - "activateAutofill": { - "message": "پر کردن خودکار را فعال کنید" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "تنظیم پر کردن خودکار در بارگذاری صفحه را در افزونه مرورگر برای همه اعضای موجود و جدید فعال کنید." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "وب‌سایت‌های در معرض خطر یا نامعتبر می‌توانند از پر کردن خودکار در بارگذاری صفحه سوء استفاده کنند." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "درباره پر کردن خودکار بیشتر بدانید" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "نوع SSO را انتخاب کنید" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden در ۷۲ ساعت اول سه بار تلاش خواهد کرد دامنه را ثبت کند. اگر دامنه ثبت نشد، رکورد DNS در میزبان خود را بررسی کرده و به‌صورت دستی ثبت کنید. اگر دامنه ثبت نشود، پس از ۷ روز از سازمان شما حذف خواهد شد." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "دامنه $DOMAIN$ ثبت نشده است. رکوردهای DNS خود را بررسی کنید.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "ثبت شده" }, - "domainStatusUnderVerification": { - "message": "در حال تأیید" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 966051ae674..80a7767a92f 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Sovellus" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Riskialttiit salasanat" }, @@ -586,6 +607,9 @@ "email": { "message": "Sähköpostiosoite" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Puhelinnumero" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sijainti" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Yksi tai useampi organisaatiokäytäntö estää yksityisen holvisi viennin." }, - "activateAutofill": { - "message": "Aktivoi automaattitäyttö" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivoi selainlaajennuksen \"Automaattitäyttö sivun avautuessa\" -asetus kaikille nykyisille ja uusille jäsenille." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Vaarantuneet tai epäluotettavat sivustot voivat väärinkäyttää sivun avautuessa suoritettavaa automaattitäyttöä." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Lue lisää automaattitäytöstä" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Valitse kertakirjautumisen tyyppi" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Vahvistettavana" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 0d661ea8d13..81d9a416f50 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Hindi" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Ang isa o higit pang mga patakaran ng organisasyon ay nagpipigil sa iyo mula sa pag-export ng iyong indibidwal na vault." }, - "activateAutofill": { - "message": "Buksan ang autofill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Maaaring pagsamantalahan ng mga nakompromisong website ang pag-autofill pagka-load ng pahina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Matuto pa tungkol sa autofill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Pumili ng uri ng SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 649c1bc5ea5..72d61e45eff 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Aucune application critique à risques" }, + "critical": { + "message": "Critique ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Non critique ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Accéder à Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Mots de passes à risque" }, @@ -586,6 +607,9 @@ "email": { "message": "Courriel" }, + "emails": { + "message": "Courriels" + }, "phone": { "message": "Téléphone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Toute personne disposant du lien" + }, + "anyOneWithPassword": { + "message": "N'importe qui avec un mot de passe défini par vous" + }, "location": { "message": "Localisation" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Prochain paiement" }, + "nextChargeDate": { + "message": "Prochaine date de facturation" + }, "plan": { "message": "Forfait" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Une ou plusieurs politiques de sécurité de l'organisation vous empêchent d'exporter votre coffre individuel." }, - "activateAutofill": { - "message": "Activer la saisie automatique" + "activateAutofillPolicy": { + "message": "Activer le remplissage automatique" }, - "activateAutofillPolicyDesc": { - "message": "Activer le paramètre de saisie automatique au chargement de la page sur l'extension du navigateur pour tous les membres existants et nouveaux." + "activateAutofillPolicyDescription": { + "message": "Activer le remplissage automatique au chargement de la page dans les paramètres de l'extension du navigateur pour tous les membres existants et nouveaux." }, - "experimentalFeature": { - "message": "Les sites web compromis ou non fiables peuvent exploiter la saisie automatique au chargement de la page." + "autofillOnPageLoadExploitWarning": { + "message": "Les sites web compromis ou non fiables peuvent exploiter le remplissage automatique au chargement de la page." }, - "learnMoreAboutAutofill": { - "message": "En savoir plus sur la saisie automatique" + "learnMoreAboutAutofillPolicy": { + "message": "En savoir plus sur le remplissage automatique" }, "selectType": { "message": "Sélectionnez le type de SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden tentera de récupérer le domaine 3 fois pendant les 72 premières heures. Si le domaine ne peut pas être réclamé, vérifiez l'enregistrement DNS dans votre hôte et réclamez manuellement. Le domaine sera supprimé de votre organisation dans 7 jours s'il n'est pas réclamé." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden tentera de réclamer le domaine dans les 72 heures. Si le domaine ne peut pas être réclamé, vérifiez votre enregistrement DNS et réclamez-le manuellement. Les domaines non réclamés sont supprimés après 7 jours." + }, + "automaticDomainClaimProcess2": { + "message": "Une fois réclamé, les membres existants ayant des domaines réclamés recevrontdes courriels à propos de la " + }, + "accountOwnershipChange": { + "message": "changement de propriétaire du compte" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ non réclamé. Vérifiez vos enregistrements DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Réclamé" }, - "domainStatusUnderVerification": { - "message": "En cours de vérification" + "domainStatusPending": { + "message": "En attente" }, "claimedDomainsDescription": { "message": "Réclamer un domaine pour réclamer les comptes des membres. La page d'identification SSO sera ignorée lors de la connexion pour les membres ayant des domaines réclamés et les administrateurs pourront supprimer les comptes réclamés." @@ -12102,13 +12147,13 @@ "message": "Vérifier maintenant." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Déverrouiller avec une clé d'accès" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Le déverrouillage par clé d'accès a échoué. Veuillez réessayer ou utiliser une autre méthode pour déverrouiller." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Aucune clé d’accès avec PRF activé n’est disponible pour le déverrouillage." }, "additionalStorageGB": { "message": "Stockage additionnel (Go)" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Vous avez utilisé tous les $GB$ Go de votre stockage chiffré. Pour continuer à stocker des fichiers, ajoutez plus de stockage." }, + "whoCanView": { + "message": "Qui peut afficher" + }, + "specificPeople": { + "message": "Personnes spécifiques" + }, + "emailVerificationDesc": { + "message": "Après avoir partagé ce lien Send, les personnes devront vérifier leur courriel avec un code pour afficher ce Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Entrez plusieurs courriels en les séparant avec une virgule." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Lorsque vous supprimez le stockage, vous recevrez un crédit de compte au prorata qui sera automatiquement appliqué à votre prochaine facture." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Protégé par courriel" + }, + "invalidSendPassword": { + "message": "Mot de passe Send invalide" } } diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 9dfe84f39e7..efbea7f59c0 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Enderezo de correo electrónico" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Número de teléfono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Non" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 8dd55800a4b..d2759d27e93 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "אין יישומים קריטיים בסיכון" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "מודיעין גישות" }, @@ -250,6 +268,9 @@ "application": { "message": "יישום" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "סיסמאות בסיכון" }, @@ -586,6 +607,9 @@ "email": { "message": "אימייל" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "טלפון" }, @@ -1365,6 +1389,12 @@ "no": { "message": "לא" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "מיקום" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "החיוב הבא" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "תוכנית" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "מדיניות ארגון אחת או יותר מונעת ממך מלייצא את הכספת האישית שלך." }, - "activateAutofill": { - "message": "הפעל מילוי אוטומטי" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "הפעל את הגדרת המילוי האוטומטי בעת טעינת עמוד בהרחבת הדפדפן עבור כל החברים הקיימים והחדשים." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "אתרים פרוצים או לא מהימנים יכולים לנצל מילוי אוטומטי בעת טעינת עמוד." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "למד עוד על מילוי אוטומטי" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "בחר סוג SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden ינסה לדרוש את הדומיין 3 פעמים במהלך 72 השעות הראשונות. אם לא ניתן לדרוש את הדומיין, בדוק את רשומת ה־DNS במארח שלך ודרוש באופן ידני. הדומיין יוסר מהארגון שלך תוך 7 ימים אם הוא לא נדרש." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ אינו נדרש. בדוק את רשומות ה־DNS שלך.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "נדרש" }, - "domainStatusUnderVerification": { - "message": "תחת אימות" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "דרוש דומיין כדי להיות הבעלים של חשבונות חברים. עמוד מזהה ה־SSO ידולג במהלך כניסה עבור חברים עם דומיינים שנדרשו ומנהלים יוכלו למחוק חשבונות שנדרשו." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 96d4b188398..b6cbd9fb3a7 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -1365,6 +1389,12 @@ "no": { "message": "नहीं" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 77f322e57d8..1567bbf14f2 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nema kritičnih aplikacija u opasnosti" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Pristup inteligenciji" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikacija" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Rizične lozinke" }, @@ -586,6 +607,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokacija" }, @@ -1750,7 +1780,7 @@ "message": "Nema članova za prikaz." }, "noMembersToExport": { - "message": "There are no members to export." + "message": "Nema članova za izvoz." }, "noEventsInList": { "message": "Nema događaja za prikaz." @@ -2541,7 +2571,7 @@ "message": "Omogućeno" }, "optionEnabled": { - "message": "Enabled" + "message": "Uključeno" }, "restoreAccess": { "message": "Vrati pristup" @@ -2641,7 +2671,7 @@ "message": "Ključ" }, "unnamedKey": { - "message": "Unnamed key" + "message": "Neimenovani ključ" }, "twoStepAuthenticatorEnterCodeV2": { "message": "Kôd za provjeru" @@ -3153,7 +3183,7 @@ "message": "Za ponovni pristup svojoj arhivi, ponovno pokreni Premium pretplatu. Ako urediš detalje arhivirane stavke prije ponovnog pokretanja, ona će biti vraćena u tvoj trezor." }, "itemRestored": { - "message": "Item has been restored" + "message": "Stavka je vraćena" }, "restartPremium": { "message": "Ponovno Pokreni Premium" @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Sljedeća naplata" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Paket" }, @@ -5195,10 +5228,10 @@ "description": "This is a verb. ex. 'Fix The Car'" }, "fixEncryption": { - "message": "Fix encryption" + "message": "Popravi šifriranje" }, "fixEncryptionTooltip": { - "message": "This file is using an outdated encryption method." + "message": "Ova datoteka koristi zastarjelu metodu šifriranja." }, "attachmentUpdated": { "message": "Privitak ažuriran" @@ -5207,7 +5240,7 @@ "message": "Postoje stari privitci u tvom trezoru koje je potrebno popraviti prije rotacije ključa za šifriranje." }, "itemsTransferred": { - "message": "Items transferred" + "message": "Stavke prenesene" }, "yourAccountsFingerprint": { "message": "Jedinstvena fraza tvog računa", @@ -5419,10 +5452,10 @@ "message": "Vrati odabrano" }, "archivedItemRestored": { - "message": "Archived item restored" + "message": "Arhivirana stavka vraćena" }, "archivedItemsRestored": { - "message": "Archived items restored" + "message": "Arhivirane stavke vraćene" }, "restoredItem": { "message": "Stavka vraćena" @@ -6385,10 +6418,10 @@ "message": "Oporavak računa uključen" }, "enrolled": { - "message": "Enrolled" + "message": "Učlanjen" }, "notEnrolled": { - "message": "Not enrolled" + "message": "Neučlanjen" }, "withdrawAccountRecovery": { "message": "Isključi oporavak računa" @@ -6568,7 +6601,7 @@ "message": "Uspješno ponovno pozvano" }, "bulkReinviteSuccessToast": { - "message": "$COUNT$ users re-invited", + "message": "Korisnika ponovno pozvano: $COUNT$", "placeholders": { "count": { "content": "$1", @@ -6577,7 +6610,7 @@ } }, "bulkReinviteLimitedSuccessToast": { - "message": "$LIMIT$ of $SELECTEDCOUNT$ users re-invited. $EXCLUDEDCOUNT$ were not invited due to the $LIMIT$ invite limit.", + "message": "$LIMIT$ od $SELECTEDCOUNT$ korisnika ponovno pozvano. $EXCLUDEDCOUNT$ nije pozvatno zbog ograničenja poziva ($LIMIT$).", "placeholders": { "limit": { "content": "$1", @@ -6914,7 +6947,7 @@ "message": "Istek trezora nije unutar zadanog vremena." }, "disableExport": { - "message": "Remove export" + "message": "Ukloni izvoz" }, "disablePersonalVaultExportDescription": { "message": "Onemogućuje korisnicima izvoz osobnog trezora." @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Jedno ili više pravila organizacija onemogućuje izvoz osobnog trezora." }, - "activateAutofill": { - "message": "Aktiviraj auto-ispunu" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivira auto-ispunu kod učitavanja stranice u dodatku za preglednik za sve postojeće i nove korisnike." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Ugrožene ili nepouzdane web stranice mogu iskoristiti auto-ispunu prilikom učitavanja stranice." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Saznaj više o auto-ispuni" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Odaberi vrstu SSO" @@ -9583,7 +9616,7 @@ "message": "Potrebna je SSO prijava" }, "emailRequiredForSsoLogin": { - "message": "Email is required for SSO" + "message": "Za SSO je potrebna e-pošta" }, "selectedRegionFlag": { "message": "Zastava odabrane regije" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden će pokušati potvrditi domenu 3 puta tijekom prva 72 sata. Ako se domena ne može potvrditi, provjeri DNS zapis na svom poslužitelju i ručno potvrdi. Domena će, ako se ne potvrdi, biti uklonjena iz vaše organizacije nakon 7 dana." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nije potvrđena. Provjeri DNS zapise.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Potvrđena" }, - "domainStatusUnderVerification": { - "message": "Provjera u tijeku" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Potvrdi domenu za vlasništvo nad članskim računima. Stranica za SSO identifikator bit će preskočena tijekom prijave za članove sa potvrđenim domenama, a administratori će moći izbrisati zatražene račune." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 65818dcb059..404cfc54c19 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nincsenek veszélyben levő kritikus alkalmazások." }, + "critical": { + "message": "Kritikus ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nem-kritikus ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Elérés intelligencia" }, @@ -250,6 +268,9 @@ "application": { "message": "Alkalmazás" }, + "applications": { + "message": "Alkalmazások" + }, "atRiskPasswords": { "message": "Veszélyes jelszavak" }, @@ -586,6 +607,9 @@ "email": { "message": "Email cím" }, + "emails": { + "message": "Email címek" + }, "phone": { "message": "Telefonszám" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nem" }, + "noAuth": { + "message": "Bárki ezzel a hivatkozással" + }, + "anyOneWithPassword": { + "message": "Bárki az általam beállított jelszóval" + }, "location": { "message": "Hely" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Következő terhelés" }, + "nextChargeDate": { + "message": "Következő terhelés dátum" + }, "plan": { "message": "Csomag" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Egy vagy több szervezeti házirend tiltja a személyes széf exportálását." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Automatikus kitöltés bekapcsolása" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Bekapcsolja az automatikus kitöltést az oldalbetöltési beállításokkal a böngésző bővítményben minden meglévő és új tag esetében." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "A veszélyeztetett vagy nem megbízható webhelyek kihasználhatják az oldal betöltéskor végrehajtott automatikus kitöltést." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "További információ az automatikus kitöltésről" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "A Bitwarden az első 72 óra során 3 alkalommal kísérli meg a tartomány ellenőrzését. Ha a tartomány nem ellenőrizhető, ellenőrizésre kerül a DNS rekordt a kiszolgálón és az ellenőrzés manuálisan történik. A tartomány 7 napon belül eltávolításra kerül, ha nem kerül igénylésre." }, + "automaticDomainClaimProcess1": { + "message": "A Bitwarden 72 órán belül megkísérli igényelni a tartományt. Ha a tartomány nem igényelhető, ellenőrizzük DNS-rekordot és igényeljünk manuálisan. A nem igényelt tartományok 7 nap múlva eltávolításra kerülnek." + }, + "automaticDomainClaimProcess2": { + "message": "Az igénylést követően az igényelt tartományokkal rendelkező meglévő tagok emailt kapnak: " + }, + "accountOwnershipChange": { + "message": "fiók tulajdonos váltás" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nincs igényelve. Ellenőrizzük a DNS rekordot.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Igényelve" }, - "domainStatusUnderVerification": { - "message": "Ellenőrzés alatt" + "domainStatusPending": { + "message": "Függő" }, "claimedDomainsDescription": { "message": "Tartomány igénylése tagfiókok birtoklására. Az igényelt tartományokkal rendelkező tagok bejelentkezése során az SSO azonosító oldal kihagyásra kerül és az adminisztrátorok törölhetik az igényelt fiókokat." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "A titkosított tárhely összes $GB$ mérete felhasználásra került. A fájlok tárolásának folytatásához adjunk hozzá további tárhelyet." }, + "whoCanView": { + "message": "Ki láthatja" + }, + "specificPeople": { + "message": "Adott személyek" + }, + "emailVerificationDesc": { + "message": "A Send hivatkozás megosztása után a személyeknek ellenőrizniük kell email címüket egy kóddal a Send megtekintéséhez." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Írjunk be több email címet vesszővel elválasztva." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "A tárhely eltávolításakor arányos számlajóváírást kapunk, amely automatikusan a következő számlára kerül." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Védett email cím" + }, + "invalidSendPassword": { + "message": "Érvénytelen a Send jelszó." } } diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 96cbe0c9e8c..3a8e798d5d1 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Tidak ada aplikasi penting berisiko" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Akses Pintar" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikasi" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Sandi berisiko" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telepon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Tidak" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokasi" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Satu atau beberapa kebijakan organisasi mencegah Anda mengekspor brankas individual Anda." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Pilih Tipe SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index c57918dfb0f..c2fbee034aa 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nessuna applicazione critica a rischio" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Intelligence sugli accessi" }, @@ -250,6 +268,9 @@ "application": { "message": "Applicazione" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Password a rischio" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefono" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Luogo" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Prossimo addebito" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Piano" }, @@ -5627,7 +5660,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedDescriptionV2": { - "message": "Copy and share this Send link. The Send will be available to anyone with the link for the next $TIME$.", + "message": "Copia e condividi questo link di invio. Sarà disponibile a chiunque ne sia a disposizione la prossima $TIME$.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "time": { @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una o più politiche dell'organizzazione ti impediscono di esportare la tua cassaforte personale." }, - "activateAutofill": { - "message": "Attiva riempimento automatico" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Attiva l'impostazione di riempimento automatico al caricamento della pagina nella estensione per il browser per tutti i membri esistenti e nuovi." + "activateAutofillPolicyDescription": { + "message": "Attiva il completamento automatico sulla pagina di caricamento delle impostazioni dell'estensione browser per tutti i membri nuovi ed esistenti." }, - "experimentalFeature": { - "message": "Siti compromessi potrebbero sfruttare il riempimento automatico al caricamento della pagina." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Ulteriori informazioni" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Seleziona tipo di SSO" @@ -10547,10 +10580,10 @@ "message": "Indice" }, "httpEventCollectorUrl": { - "message": "HTTP Event Collector URL" + "message": "URL del collettore di eventi HTTP" }, "httpEventCollectorToken": { - "message": "HTTP Event Collector Token" + "message": "Token del collettore eventi HTTP" }, "selectAPlan": { "message": "Seleziona un piano" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden tenterà di verificare il dominio 3 volte durante le prossime 72 ore. Se il dominio non può essere acquisito, controlla il record DNS del tuo servizio di hosting e procedi manualmente. Il dominio sarà rimosso dall'organizzazione dopo 7 giorni se la procedura non andrà a buon fine." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden proverà ad ottenere il dominio nelle prossime 72 ore. Se il dominio non può essere ottenuto, verifica il tuo record DNS ed ottienilo manualmente. I domini non ottenuti vengono rimossi dopo 7 giorni." + }, + "automaticDomainClaimProcess2": { + "message": "Una volta ottenuto, i membri con domini riservati verranno contattati via e-mail per " + }, + "accountOwnershipChange": { + "message": "modifica della proprietà dell'account" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ non verificato. Controlla il record DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Verificato" }, - "domainStatusUnderVerification": { - "message": "In attesa di verifica" + "domainStatusPending": { + "message": "In attesa" }, "claimedDomainsDescription": { "message": "Richiedi un dominio per avere a disposizione account membri. La pagina SSO sarà saltata durante il login per i membri con domini rivendicati e gli amministratori saranno in grado di eliminare gli account rivendicati." @@ -12102,10 +12147,10 @@ "message": "Verifica adesso." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Sblocca con passkey" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Impossibile sbloccare con passkey. Riprova o utilizza un altro metodo." }, "noPrfCredentialsAvailable": { "message": "No PRF-enabled passkeys are available for unlock." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Hai usato tutti i $GB$ GB del tuo spazio di archiviazione crittografato. Per archiviare altri file, aggiungi altro spazio." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Quando rimuovi spazio di archiviazione, riceverai un credito che sarà automaticamente applicato al tuo prossimo pagamento." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protetta" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 25ba0d15748..2920a2e98b6 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "危険にさらされた重要なアプリケーションはありません" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "アクセス インテリジェンス" }, @@ -250,6 +268,9 @@ "application": { "message": "アプリ" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "リスクがあるパスワード" }, @@ -586,6 +607,9 @@ "email": { "message": "メールアドレス" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話番号" }, @@ -1365,6 +1389,12 @@ "no": { "message": "いいえ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "場所" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "1 つ以上の組織ポリシーにより、個人の保管庫をエクスポートできません。" }, - "activateAutofill": { - "message": "自動入力を有効にする" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "すべての既存および新規メンバーのブラウザ拡張機能のページ読み込み設定で、自動入力を有効にします。" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "ウイルス感染したり信頼できないウェブサイトは、ページの読み込み時の自動入力を悪用できてしまいます。" + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "自動入力についての詳細" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO のタイプを選択" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index cdc4d476edc..28fb7e7a76d 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ელ-ფოსტა" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ტელეფონი" }, @@ -1365,6 +1389,12 @@ "no": { "message": "არა" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index c5a2ccd47f3..47af931229d 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index 912649d6ac4..c3c3035fdcb 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ಇಮೇಲ್" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ಫೋನ್‌" }, @@ -1365,6 +1389,12 @@ "no": { "message": "ಇಲ್ಲ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index c5d1293c528..add9f3bc8ae 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "이메일" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "전화번호" }, @@ -1365,6 +1389,12 @@ "no": { "message": "아니오" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO 유형 선택" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 13ef6411be7..718439c721e 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nav riskam pakļautu būtisku lietotņu" }, + "critical": { + "message": "Būtiski ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Nav būtiski ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Piekļuves inteliģence" }, @@ -250,6 +268,9 @@ "application": { "message": "Lietotne" }, + "applications": { + "message": "Lietotnes" + }, "atRiskPasswords": { "message": "Riskam pakļautās paroles" }, @@ -586,6 +607,9 @@ "email": { "message": "E-pasts" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Tālrunis" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nē" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Atrašanās vieta" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Nākamais maksājums" }, + "nextChargeDate": { + "message": "Nākamās apmaksas datums" + }, "plan": { "message": "Plāns" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Viens vai vairāki apvienības nosacījumi neļauj izgūt privātās glabātavas saturu." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Iespējot automātisko aizpildi" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Pārlūka paplašinājumā iespējot automātisko aizpildi lapas ielādes brīdī visiem esošajiem un jaunajiem dalībniekiem." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Pārveidotās vai neuzticamās tīmekļvietnēs automātiskā aizpilde lapas ielādes laikā var tikt ļaunprātīgi izmantota." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Uzzināt vairāk par automātisko aizpildi" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden mēģinās pārbaudīt domēnu 3 reizes pirmajās 72 stundās. Ja domēnu nevarēs pieteikt, būs jāpārbauda DNS ieraksts saimniekdatorā un tas pašrocīgi jāpiesaka. Domēns tiks noņemts no apvienības pēc 7 dienām, ja tas nebūs pieteikts." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nav pieteikts. Jāpārbauda DNS ieraksts.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Pieteikts" }, - "domainStatusUnderVerification": { - "message": "Apliecināšanā" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Iegūsti domēna piederību, lai iegūtu dalībnieku kontu īpašumtiesības. SSO identificētāja lapa tiks izlaista, kad pieteiksies dalībnieki, kuru kontu piederība ir atkarīga no domēna, un pārvaldītāji varēs izdzēst šāda veida kontus." @@ -12102,13 +12147,13 @@ "message": "Apliecini tagad!" }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Atslēgt ar piekļuves atslēgu" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Neizdevās atslēgt ar piekļuves atslēgu. Lūgums mēģināt vēlreiz vai izmantot citu atslēgšanas veidu." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Atslēgšanai nav pieejama neviena PRF iespējota piekļuves atslēga." }, "additionalStorageGB": { "message": "Papildu krātuve GB" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Kad noņemsi krātuvi, saņemsi konta kredītu noteiktā apjomā, kas tiks automātiski izmantots nākamajā rēķinā." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-pasts aizsargāts" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 5bae262f5ba..2fe84b98d75 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "ഇമെയിൽ" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "ഫോൺ" }, @@ -1365,6 +1389,12 @@ "no": { "message": "അല്ല" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index d0ef79397b1..fd55de6cab7 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "कोणतेही महत्त्वाचे अ‍ॅप्लिकेशन्स धोक्यात नाहीत" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "अ‍ॅक्सेस इंटेलिजेंस" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "धोकादायक पासवर्ड" }, @@ -586,6 +607,9 @@ "email": { "message": "ईमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index c5a2ccd47f3..47af931229d 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index e9dca7aa77a..06b35a2977c 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Program" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nei" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Sted" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flere regler i organisasjonsoppsettet forhindrer deg i å eksportere ditt personlige hvelv." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Velg SSO-type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 7e638d3ab8b..6887e331c4e 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "इमेल" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "फोन" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "सबै अवस्थित र नयाँ सदस्यहरूको लागि ब्राउजर एक्सटेन्सनको पृष्ठ लोड सेटिङमा स्वत: भरण विकल्प सक्रिय गर्नुहोस्।" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index ac68180e886..842df8eb3e0 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Geen kritische applicaties in gevaar" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Toegangsintelligentie" }, @@ -250,6 +268,9 @@ "application": { "message": "Applicatie" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Wachtwoorden in gevaar" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mailadres" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefoonnummer" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nee" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locatie" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Volgende betaling" }, + "nextChargeDate": { + "message": "Volgende datum van betaling" + }, "plan": { "message": "Pakket" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Organisatiebeleid voorkomt dat je je persoonlijke kluis exporteert." }, - "activateAutofill": { - "message": "Automatisch invullen activeren" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activeer de automatisch invullen wanneer de pagina geladen is instelling in de browser extensie voor bestaande en nieuwe gebruikers." + "activateAutofillPolicyDescription": { + "message": "Activeer de \"automatisch invullen wanneer de pagina geladen is\"-instelling in de browserextensie voor alle bestaande en nieuwe gebruikers." }, - "experimentalFeature": { - "message": "Gehackte of onbetrouwbare websites kunnen automatisch invullen bij laden van pagina misbruiken." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Meer info over automatisch invullen" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecteer Type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden probeert het domein gedurende de eerste 72 uur driemaal te verifiëren. Als het domein niet geverifieerd kan worden, controleer dan het DNS-record bij je host en verifieer handmatig. Het domein wordt binnen 7 dagen verwijderd uit je organisatie als het niet geverifieerd is." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden probeert het domein binnen 72 uur te claimen. Als het domein niet kan worden geclaimd, controleer dan je DNS-record en claim handmatig. Niet-geclaimde domeinen worden na 7 dagen verwijderd." + }, + "automaticDomainClaimProcess2": { + "message": "Eenmaal geclaimd, zullen bestaande leden met geclaimde domeinen gemaild worden over de " + }, + "accountOwnershipChange": { + "message": "accounteigendom wijziging" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ niet geverifieerd. Controleer je DNS-records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Geverifieerd" }, - "domainStatusUnderVerification": { - "message": "Gebruikersverificatie" + "domainStatusPending": { + "message": "In behandeling" }, "claimedDomainsDescription": { "message": "Claim een domein voor eigendom van ledenaccounts. De SSO-identificatiepagina wordt overgeslagen tijdens het inloggen voor leden met geclaimde domeinen en beheerders kunnen geclaimde accounts verwijderen." @@ -12102,13 +12147,13 @@ "message": "Nu verifiëren." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Ontgrendelen met passkey" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Ontgrendelen met passkey mislukt. Probeer het opnieuw of gebruik een andere ontgrendelingsmethode." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Geen PRF-ingeschakelde passkeys beschikbaar om te ontgrendelen." }, "additionalStorageGB": { "message": "Extra opslagruimte (GB)" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Wanneer je opslag verwijdert, krijg je op je volgende rekening automatisch pro-rata rekeningkrediet." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail beveiligd" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index ab58d48f3a2..893de2f2f33 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index c5a2ccd47f3..47af931229d 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 90f180aa7fa..a7725961a33 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Brak zagrożonych aplikacji krytycznych" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Dostęp do informacji" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikacja" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Zagrożone hasła" }, @@ -586,6 +607,9 @@ "email": { "message": "Adres e-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Lokalizacja" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Co najmniej jedna zasada organizacji uniemożliwia wyeksportowanie osobistego sejfu." }, - "activateAutofill": { - "message": "Włącz autouzupełnianie" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktywuj autouzupełnianie podczas wczytywania strony w rozszerzeniu przeglądarki dla wszystkich istniejących i nowych użytkowników." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Zaatakowane lub niezaufane witryny internetowe mogą wykorzystać funkcję autouzupełniania podczas wczytywania strony, aby wyrządzić szkody." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Dowiedz się więcej o autouzupełnianiu" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Wybierz rodzaj logowania jednokrotnego SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden spróbuje zgłosić domenę 3 razy w ciągu pierwszych 72 godzin. Jeśli nie można zgłosić domeny, sprawdź rekord DNS w swoim serwerze i sprawdź go ręcznie. Domena zostanie usunięta z Twojej organizacji w ciągu 7 dni, jeśli nie zostanie zgłoszona." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nie została zgłoszona. Sprawdź swój rekord DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Zgłoszono" }, - "domainStatusUnderVerification": { - "message": "W trakcie weryfikacji" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Zgłoś domenę, aby posiadać konta członków. Strona identyfikatora SSO zostanie pominięta podczas logowania dla członków z zadeklarowanymi domenami, a administratorzy będą mogli usuwać zadeklarowane konta." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index 632d0c79b7b..53461da1741 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nenhum aplicativo crítico em risco" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Inteligência de acesso" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicativo" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Senhas em risco" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Localização" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Próxima cobrança" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plano" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Uma ou mais políticas da organização impedem que você exporte seu cofre individual." }, - "activateAutofill": { - "message": "Ativar preenchimento automático" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Ative a configuração de preenchimento automático no carregamento da página na extensão do navegador para todos os membros existentes e novos." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Sites comprometidos ou não confiáveis podem tomar vantagem do preenchimento automático ao carregar a página." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Saiba mais sobre o preenchimento automático" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selecionar tipo de SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "O Bitwarden tentará reivindicar o domínio 3 vezes durante as primeiras 72 horas. Se o domínio não poder ser reivindicado, confira o registro de DNS no seu servidor e reivindique manualmente. Se não for reivindicado, o domínio será removido da sua organização em 7 dias." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ não reivindicado. Confira os seus registros de DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Reivindicado" }, - "domainStatusUnderVerification": { - "message": "Em verificação" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Reivindique um domínio para ser o proprietário das contas dos membros. A página do identificador do SSO será pulada durante a autenticação dos membros com os domínios reivindicados, e os administradores poderão apagar contas reivindicadas." @@ -12102,13 +12147,13 @@ "message": "Verifique agora." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Desbloquear com chave de acesso" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Falha no desbloqueio com a chave de acesso. Tente novamente ou use outro método de desbloqueio." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Nenhuma chave de acesso com PRF está disponível para desbloqueio." }, "additionalStorageGB": { "message": "GB de armazenamento adicional" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Você usou todos os $GB$ GB do seu armazenamento criptografado. Para continuar armazenando arquivos, adicione mais armazenamento." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Quando você remover o armazenamento, você receberá um crédito de conta proporcional que irá automaticamente para sua próxima fatura." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail protegido" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index c99bd97d750..9436fdb1f8f 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Não há aplicações críticas em risco" }, + "critical": { + "message": "Críticas ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Não críticas ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Inteligência de Acesso" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicação" }, + "applications": { + "message": "Aplicações" + }, "atRiskPasswords": { "message": "Palavras-passe em risco" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "E-mails" + }, "phone": { "message": "Telefone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Não" }, + "noAuth": { + "message": "Qualquer pessoa com o link" + }, + "anyOneWithPassword": { + "message": "Qualquer pessoa com uma palavra-passe definida por si" + }, "location": { "message": "Localização" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Próxima cobrança" }, + "nextChargeDate": { + "message": "Próxima data de cobrança" + }, "plan": { "message": "Plano" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Uma ou mais políticas da organização impedem-no de exportar o seu cofre pessoal." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Ativar o preenchimento automático" }, - "activateAutofillPolicyDesc": { - "message": "Ative a definição de preenchimento automático ao carregar a página na extensão do navegador para todos os membros existentes e novos." + "activateAutofillPolicyDescription": { + "message": "Ative a definição de preenchimento automático ao carregar a página na extensão do navegador para todos os membros atuais e novos." }, - "experimentalFeature": { - "message": "Os sites comprometidos ou não fiáveis podem explorar o preenchimento automático ao carregar a página." + "autofillOnPageLoadExploitWarning": { + "message": "Os sites comprometidos ou não confiáveis podem explorar o preenchimento automático ao carregar a página." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Saber mais sobre o preenchimento automático" }, "selectType": { @@ -8035,7 +8068,7 @@ } }, "inputMinValue": { - "message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.", + "message": "O valor introduzido deve ser, no mínimo, $MIN$.", "placeholders": { "min": { "content": "$1", @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "O Bitwarden tentará reivindicar o domínio 3 vezes durante as primeiras 72 horas. Se o domínio não puder ser reivindicado, verifique o registo DNS no seu anfitrião e reivindique manualmente. O domínio será removido da sua organização em 7 dias se não for reivindicado." }, + "automaticDomainClaimProcess1": { + "message": "O Bitwarden tentará reclamar o domínio no prazo de 72 horas. Caso não seja possível reclamar o domínio, verifique o registo DNS e faça a reclamação manualmente. Os domínios não reclamados são removidos após 7 dias." + }, + "automaticDomainClaimProcess2": { + "message": "Após a reclamação, os membros existentes com domínios reclamados serão notificados por e-mail sobre a " + }, + "accountOwnershipChange": { + "message": "alteração da titularidade da conta" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ não reivindicado. Verifique o seu registo DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Reivindicado" }, - "domainStatusUnderVerification": { - "message": "Sob verificação" + "domainStatusPending": { + "message": "Pendente" }, "claimedDomainsDescription": { "message": "Reivindique um domínio para possuir contas de membros. A página do identificador SSO será ignorada durante o início de sessão para membros com domínios reivindicados e os administradores poderão eliminar contas reivindicadas." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Utilizou os $GB$ GB do seu armazenamento encriptado. Para continuar a guardar ficheiros, adicione mais espaço de armazenamento." }, + "whoCanView": { + "message": "Quem pode ver" + }, + "specificPeople": { + "message": "Pessoas específicas" + }, + "emailVerificationDesc": { + "message": "Após partilhar este Send através do link, os indivíduos terão de verificar o e-mail com um código para poderem ver este Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Introduza vários e-mails, separados por vírgula." + }, + "emailPlaceholder": { + "message": "utilizador@bitwarden.com , utilizador@acme.com" + }, "whenYouRemoveStorage": { "message": "Ao remover espaço de armazenamento, receberá um crédito proporcional na conta, que será automaticamente aplicado na sua próxima fatura." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "E-mail protegido" + }, + "invalidSendPassword": { + "message": "Palavra-passe do Send inválida" } } diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 112b058d80b..3ec09c44e44 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nicio aplicație critică în pericol" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplicație" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "E-mail" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nu" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Locație" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Una sau mai multe politici de organizație împiedică exportul seifului individual." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Selectare tip SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 0963c04140d..e865341d64c 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Никакие критичные приложения не подвергаются риску" }, + "critical": { + "message": "Критичные ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Не критичные ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Приложение" }, + "applications": { + "message": "Приложения" + }, "atRiskPasswords": { "message": "Пароли, подверженные риску" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Нет" }, + "noAuth": { + "message": "Любой, у кого есть ссылка" + }, + "anyOneWithPassword": { + "message": "Любой, у кого есть установленный вами пароль" + }, "location": { "message": "Местоположение" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Следующий платеж" }, + "nextChargeDate": { + "message": "Дата следующего платежа" + }, "plan": { "message": "План" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Одна или несколько политик организации запрещают вам экспортировать личное хранилище." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Активировать автозаполнение" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Включить автозаполнение при загрузке страницы в расширении браузера для всех существующих и новых участников." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Взломанные или недоверенные сайты могут внедрить вредоносный код во время автозаполнения при загрузке страницы." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Узнать больше об автозаполнении" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden попытается зарегистрировать домен 3 раза в течение первых 72 часов. Если домен не удастся зарегистрировать, проверьте запись DNS на вашем хосте и зарегистрируйте вручную. Домен будет удален из вашей организации через 7 дней, если он не будет зарегистрирован." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden попытается заявить права на домен в течение 72 часов. Если домен не может быть заявлен, проверьте свою запись в DNS и подайте заявку вручную. Невостребованные домены удаляются через 7 дней." + }, + "automaticDomainClaimProcess2": { + "message": "После подачи заявки существующим участникам с заявленными доменами будет отправлено электронное письмо с информацией об " + }, + "accountOwnershipChange": { + "message": "изменении владельца аккаунта" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ не зарегистрирован. Проверьте записи DNS.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Зарегистрирован" }, - "domainStatusUnderVerification": { - "message": "Проверяется" + "domainStatusPending": { + "message": "Ожидание" }, "claimedDomainsDescription": { "message": "Заявите права на домен, чтобы владеть аккаунтами членов. Страница идентификатора SSO будет пропущена при пользователей с заявленными доменами, а администраторы смогут удалять заявленные аккаунты." @@ -12102,13 +12147,13 @@ "message": "Подтвердить сейчас." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Разблокировать при помощи passkey" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Для разблокировки недоступны passkeys с поддержкой PRF." }, "additionalStorageGB": { "message": "Дополнительные ГБ хранилища" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Вы использовали все $GB$ вашего зашифрованного хранилища. Чтобы продолжить хранение файлов, добавьте дополнительное хранилище." }, + "whoCanView": { + "message": "Кто может просматривать" + }, + "specificPeople": { + "message": "Конкретные пользователи" + }, + "emailVerificationDesc": { + "message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Введите несколько email, разделяя их запятой." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "При удалении хранилища вы получите пропорциональную сумму на свой счет, которая автоматически пойдет на оплату вашего следующего счета." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email защищен" + }, + "invalidSendPassword": { + "message": "Неверный пароль Send" } } diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index 5f3f4974bd5..af545c77eea 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "වි-තැපෑල" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "දුරකථනය" }, @@ -1365,6 +1389,12 @@ "no": { "message": "නැහැ" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 459b0b28973..ecf51e84c0b 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Nie sú ohrozené žiadne kritické aplikácie" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Prehľad o prístupe" }, @@ -250,6 +268,9 @@ "application": { "message": "Aplikácia" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Ohrozených hesiel" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefón" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nie" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Poloha" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Ďalšia platba" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plán" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Aktivujte nastavenie automatického vypĺňania pri načítaní stránky v rozšírení pre prehliadač pre všetkých súčasných a nových členov." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Vyberte typ SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden sa pokúsi privlastniť doménu 3 krát počas prvých 72 hodín. Ak sa doménu nepodarilo privlastniť, skontrolujte DNS záznam u svojho hostiteľa a privlastnite manuálne. Doména bude z organizácie odstránená po 7 dňoch ak nie je privlastnená." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ nie je privlastnená. Overte si DNS záznam.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Privlastnená" }, - "domainStatusUnderVerification": { - "message": "Overuje sa" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Privlastnite si doménu, aby ste mohli vlastniť členské účty. Stránka s identifikátorom SSO bude pri prihlásení členov s privlastnenými doménami preskočená a správcovia budú môcť členské účty odstrániť." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Použili ste všetkých $GB$ GB vášho šifrovaného úložiska. Ak chcete uložiť ďalšie súbory, pridajte viac úložiska." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "Ak odstránite úložisko, dostanete na váš účet proporcionálny kredit ktorý sa automaticky použije pri najbližšej faktúre." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email chránený" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 89d96c07bb7..cbbc276b371 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Analiza dostopa" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -332,7 +353,7 @@ } }, "totalMembers": { - "message": "Total members" + "message": "Skupaj članov" }, "atRiskApplications": { "message": "At-risk applications" @@ -456,10 +477,10 @@ "message": "Zapisek" }, "privateNote": { - "message": "Private note" + "message": "Privaten zapisek" }, "note": { - "message": "Note" + "message": "Zapisek" }, "customFields": { "message": "Polja po meri" @@ -586,6 +607,9 @@ "email": { "message": "E-pošta" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -893,7 +917,7 @@ "message": "Zavarovan zapisek" }, "typeNote": { - "message": "Note" + "message": "Zapisek" }, "typeSshKey": { "message": "SSH key" @@ -986,7 +1010,7 @@ "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Nov zapisek", "description": "Header for new note item type" }, "newItemHeaderSshKey": { @@ -1014,7 +1038,7 @@ "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Uredi zapisek", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { @@ -1042,7 +1066,7 @@ "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Poglej zapisek", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { @@ -1132,7 +1156,7 @@ "message": "Copy website" }, "copyNotes": { - "message": "Copy notes" + "message": "Kopiraj zapisek" }, "copyAddress": { "message": "Copy address" @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -2306,11 +2336,11 @@ "message": "Orodja" }, "importNoun": { - "message": "Import", + "message": "Uvozi", "description": "The noun form of the word Import" }, "importVerb": { - "message": "Import", + "message": "Uvozi", "description": "The verb form of the word Import" }, "importData": { @@ -2325,7 +2355,7 @@ "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new item instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsLoginLink": { - "message": "new login", + "message": "nova prijava", "description": "This will be part of a larger sentence, that will read like this: If you don't have any data to import, you can create a new login instead. (Optional second half: You may need to wait until your administrator confirms your organization membership.)" }, "onboardingImportDataDetailsPartTwoNoOrgs": { @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -3444,7 +3477,7 @@ "message": "General information" }, "organizationName": { - "message": "Organization name" + "message": "Naziv organizacije" }, "accountOwnedBusiness": { "message": "This account is owned by a business." @@ -3821,7 +3854,7 @@ "message": "Client owner email" }, "owner": { - "message": "Owner" + "message": "Lastnik" }, "ownerDesc": { "message": "Manage all aspects of your organization, including billing and subscriptions" @@ -4685,7 +4718,7 @@ "message": "My organization" }, "organizationInfo": { - "message": "Organization info" + "message": "Organizacija" }, "deleteOrganization": { "message": "Delete organization" @@ -6268,7 +6301,7 @@ "message": "Send request" }, "addANote": { - "message": "Add a note" + "message": "Dodaj zapisek" }, "bitwardenSecretsManager": { "message": "Bitwarden Secrets Manager" @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Več o samodejnem izpolnjevanju" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -8119,7 +8152,7 @@ "message": "Members" }, "reporting": { - "message": "Reporting" + "message": "Poročanje" }, "numberOfUsers": { "message": "Number of users" @@ -9723,7 +9756,7 @@ "message": "Secrets Manager plan price" }, "passwordManager": { - "message": "Password Manager" + "message": "Upravitelj gesel" }, "freeOrganization": { "message": "Free Organization" @@ -10011,7 +10044,7 @@ "message": "Read release blog" }, "adminConsole": { - "message": "Admin Console" + "message": "Nadzorna plošča" }, "providerPortal": { "message": "Provider Portal" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -11437,7 +11482,7 @@ "message": "Item removed from favorites" }, "copyNote": { - "message": "Copy note" + "message": "Kopiraj zapisek" }, "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." @@ -11775,7 +11820,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "generatorNudgeTitle": { - "message": "Quickly create passwords" + "message": "Hitro ustvarite gesla" }, "generatorNudgeBodyOne": { "message": "Easily create strong and unique passwords by clicking on", @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 16d728c73c4..43b41d22ca6 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Pristupi inteligenciji" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Imejl" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ne" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sr_CY/messages.json b/apps/web/src/locales/sr_CY/messages.json index 078b342048f..147780e2c2b 100644 --- a/apps/web/src/locales/sr_CY/messages.json +++ b/apps/web/src/locales/sr_CY/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Нема критичних апликација у ризику" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Приступи интелигенцији" }, @@ -250,6 +268,9 @@ "application": { "message": "Апликација" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Лозинке под ризиком" }, @@ -586,6 +607,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Не" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Локација" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Следеће пуњење" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "План" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Једна или више полиса ваше организације вас спречава да извезете ваш сеф." }, - "activateAutofill": { - "message": "Активирати ауто-пуњење" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Активирајте ауто-пуњење при учитавању странице на додатку прегледача за све постојеће и нове чланове." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Компромитоване или непоуздане веб локације могу да искористе ауто-пуњење при учитавању странице." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Сазнајте више о ауто-пуњење" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Одабрати тип SSO-а" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Захтевано" }, - "domainStatusUnderVerification": { - "message": "Под провером" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Захтевајте домен на сопствени рачуни чланова. Страница SSO идентификатора биће прескочена током пријаве за чланове са захтевним доменима и администратори ће моћи да избрише захтевене рачуне." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index e73afc42759..0f1f827c7b5 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Inga kritiska applikationer i riskzonen" }, + "critical": { + "message": "Kritiska ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Inte kritiska ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Applikation" }, + "applications": { + "message": "Applikationer" + }, "atRiskPasswords": { "message": "Lösenord i riskzonen" }, @@ -586,6 +607,9 @@ "email": { "message": "E-post" }, + "emails": { + "message": "E-post" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Nej" }, + "noAuth": { + "message": "Vem som helst med länken" + }, + "anyOneWithPassword": { + "message": "Alla som har ett lösenord inställt av dig" + }, "location": { "message": "Plats" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Nästa betalning" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -5932,7 +5965,7 @@ "message": "Gain full visibility into credential health, including shared and unshared items." }, "centralizeDataOwnershipBenefit2": { - "message": "Easily transfer items during member offboarding and succession, ensuring there are no access gaps." + "message": "Överför enkelt objekt under medlemmens offboarding och succession, vilket garanterar att det inte finns några åtkomstluckor." }, "centralizeDataOwnershipBenefit3": { "message": "Ge alla användare ett dedikerat \"Mina objekt\"-utrymme för att hantera sina egna inloggningar." @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "En eller flera organisationspolicyer hindrar dig från att exportera ditt enskilda valv." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Aktivera autofyll" }, - "activateAutofillPolicyDesc": { - "message": "Aktivera inställningen för automatisk ifyllnad vid sidladdning i webbläsartillägget för alla befintliga och nya medlemmar." + "activateAutofillPolicyDescription": { + "message": "Aktivera autofyll på sidladdningsinställningar på webbläsartillägget för alla befintliga och nya medlemmar." }, - "experimentalFeature": { - "message": "Komprometterade eller opålitliga webbplatser kan utnyttja automatisk ifyllning vid sidladdning." + "autofillOnPageLoadExploitWarning": { + "message": "Komprometterade eller ej betrodda webbplatser kan utnyttja automatisk ifyllnad vid sidladdning." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Läs mer om autofyll" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden kommer att försöka göra anspråk på domänen 3 gånger under de första 72 timmarna. Om domänen inte kan göras anspråk på, kontrollera DNS-posten i din host och gör anspråk manuellt. Domänen kommer att tas bort från din organisation inom 7 dagar om den inte görs anspråk på." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden kommer att försöka göra anspråk på domänen inom 72 timmar. Om domänen inte kan hävdas, kontrollera din DNS-post och anspråk manuellt. Domäner som inte gjorts anspråk på tas bort efter 7 dagar." + }, + "automaticDomainClaimProcess2": { + "message": "När de har gjorts anspråk på kommer befintliga medlemmar med dessa domäner kommer att mailas om " + }, + "accountOwnershipChange": { + "message": "byte av kontoäganderätt" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ inte hävdad. Kontrollera dina DNS-poster.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Ägd" }, - "domainStatusUnderVerification": { - "message": "Under verifiering" + "domainStatusPending": { + "message": "Väntande" }, "claimedDomainsDescription": { "message": "Begär en domän för att äga medlemskonton. SSO-identifierarsidan kommer att hoppas över under inloggningen för medlemmar med namngivna domäner och administratörer kommer att kunna ta bort begärda konton." @@ -12102,13 +12147,13 @@ "message": "Verifiera nu." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Lås upp med lösennyckel" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Det gick inte att låsa upp med lösennyckel. Försök igen eller använd en annan upplåsningsmetod." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Inga PRF-aktiverade lösennycklar finns tillgängliga för upplåsning." }, "additionalStorageGB": { "message": "Ytterligare lagringsplats (GB)" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "Du har använt alla $GB$ GB av din krypterade lagring. För att fortsätta lagra filer, lägg till mer lagringsutrymme." }, + "whoCanView": { + "message": "Vem kan se" + }, + "specificPeople": { + "message": "Specifika personer" + }, + "emailVerificationDesc": { + "message": "Efter att ha delat denna Send-länk kommer individer att behöva verifiera sin e-post med en kod för att visa denna Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Ange flera e-postadresser genom att separera dem med kommatecken." + }, + "emailPlaceholder": { + "message": "användare@bitwarden.com , användare@acme.com" + }, "whenYouRemoveStorage": { "message": "När du tar bort lagring kommer du att få en proportionell kontokredit som automatiskt går mot din nästa faktura." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Ogiltigt Send-lösenord" } } diff --git a/apps/web/src/locales/ta/messages.json b/apps/web/src/locales/ta/messages.json index 931fd3be2f9..df578c8c2aa 100644 --- a/apps/web/src/locales/ta/messages.json +++ b/apps/web/src/locales/ta/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "முக்கியமான பயன்பாடுகளில் ஆபத்து ஏதும் இல்லை" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "அணுகல் நுண்ணறிவு" }, @@ -250,6 +268,9 @@ "application": { "message": "பயன்பாடு" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "ஆபத்தான கடவுச்சொற்கள்" }, @@ -586,6 +607,9 @@ "email": { "message": "மின்னஞ்சல்" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "தொலைபேசி" }, @@ -1365,6 +1389,12 @@ "no": { "message": "இல்லை" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "இடம்" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "ஒன்று அல்லது அதற்கு மேற்பட்ட நிறுவன கொள்கைகள் உங்கள் தனிப்பட்ட பெட்டகத்தை ஏற்றுமதி செய்வதைத் தடுக்கின்றன." }, - "activateAutofill": { - "message": "தானாக நிரப்புவதை செயல்படுத்து" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "ஏற்கனவே உள்ள மற்றும் புதிய அனைத்து உறுப்பினர்களுக்கும் உலாவி நீட்டிப்பில் பக்கம் ஏற்றப்படும்போது தானாக நிரப்பும் அமைப்பைச் செயல்படுத்து." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "சந்தேகத்திற்குரிய அல்லது நம்பத்தகாத வலைத்தளங்கள் பக்கம் ஏற்றப்படும்போது தானாக நிரப்புவதைப் பயன்படுத்திக் கொள்ளலாம்." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "தானாக நிரப்புதல் பற்றி மேலும் அறிக" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "SSO வகையைத் தேர்ந்தெடுக்கவும்" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "முதல் 72 மணிநேரத்திற்குள் 3 முறை டொமைனைக் கோர Bitwarden முயற்சிக்கும். டொமைனைக் கோர முடியவில்லையெனில், உங்கள் ஹோஸ்டில் உள்ள DNS பதிவைச் சரிபார்த்து, கைமுறையாகக் கோரவும். டொமைன் கோரப்படாவிட்டால் 7 நாட்களில் உங்கள் அமைப்பிலிருந்து அகற்றப்படும்." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ கோரப்படவில்லை. உங்கள் DNS பதிவுகளைச் சரிபார்க்கவும்.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "கோரப்பட்டது" }, - "domainStatusUnderVerification": { - "message": "சரிபார்ப்பில் உள்ளது" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "உறுப்பினர் கணக்குகளைச் சொந்தமாக்க ஒரு டொமைனைக் கோரவும். கோரப்பட்ட டொமைன்கள் கொண்ட உறுப்பினர்களுக்காக உள்நுழையும் போது SSO அடையாளங்காட்டி பக்கம் தவிர்க்கப்படும், மேலும் நிர்வாகிகள் கோரப்பட்ட கணக்குகளை நீக்க முடியும்." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index c5a2ccd47f3..47af931229d 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Phone" }, @@ -1365,6 +1389,12 @@ "no": { "message": "No" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index 48a0e043a4b..aa3d317b324 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "No critical applications at risk" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Application" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "At-risk passwords" }, @@ -586,6 +607,9 @@ "email": { "message": "อีเมล" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "โทรศัพท์" }, @@ -1365,6 +1389,12 @@ "no": { "message": "ไม่ใช่" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Location" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "One or more organization policies prevents you from exporting your individual vault." }, - "activateAutofill": { - "message": "Activate auto-fill" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Activate the auto-fill on page load setting on the browser extension for all existing and new members." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Select SSO type" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ not claimed. Check your DNS records.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Claimed" }, - "domainStatusUnderVerification": { - "message": "Under verification" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index c356289ab50..f38800d72f7 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Risk altında olan kritik uygulama yok" }, + "critical": { + "message": "Kritik ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Kritik değil ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "Uygulama" }, + "applications": { + "message": "Uygulamalar" + }, "atRiskPasswords": { "message": "Riskli parolalar" }, @@ -586,6 +607,9 @@ "email": { "message": "E-posta" }, + "emails": { + "message": "E-postalar" + }, "phone": { "message": "Telefon" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Hayır" }, + "noAuth": { + "message": "Bağlantıya sahip olan herkes" + }, + "anyOneWithPassword": { + "message": "Belirlediğiniz parolaya sahip olan herkes" + }, "location": { "message": "Konum" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Sonraki ödeme" }, + "nextChargeDate": { + "message": "Sonraki ödeme tarihi" + }, "plan": { "message": "Paket" }, @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "Bir veya daha fazla kuruluş ilkesi, kişisel kasanızı dışa aktarmanızı engelliyor." }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "Otomatik doldurmayı etkinleştir" }, - "activateAutofillPolicyDesc": { + "activateAutofillPolicyDescription": { "message": "Tüm mevcut ve yeni üyeler için tarayıcı uzantısındaki \"sayfa yüklendiğinde otomatik doldur\" ayarını etkinleştir." }, - "experimentalFeature": { + "autofillOnPageLoadExploitWarning": { "message": "Ele geçirilmiş veya güvenilmeyen web siteleri sayfa yüklenirken otomatik doldurmayı suistimal edebilir." }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "Otomatik doldurma hakkında bilgi alın" }, "selectType": { @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden, ilk 72 saat içinde etki alanını 3 kez talep etmeye çalışacaktır. Etki alanı talep edilemezse, barındırıcınızdaki DNS kaydını kontrol edin ve manuel olarak talep edin. Etki alanı talep edilmezse, 7 gün içinde kuruluşunuzdan kaldırılacaktır." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ alınmadı. DNS kayıtlarınızı kontrol edin.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Alındı" }, - "domainStatusUnderVerification": { - "message": "Doğrulama altında" + "domainStatusPending": { + "message": "Beklemede" }, "claimedDomainsDescription": { "message": "Üye hesaplarını sahip olmak için bir alan adı talep edin. Alan adı talep eden üyelerin oturum açma sırasında SSO tanımlayıcı sayfası atlanacak ve yöneticiler talep edilen hesapları silebilecek." @@ -12102,13 +12147,13 @@ "message": "Şimdi doğrulayın." }, "unlockWithPasskey": { - "message": "Unlock with passkey" + "message": "Kilidi geçiş anahtarıyla aç" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "Kilit geçiş anahtarıyla açılamadı. Lütfen yeniden deneyin veya başka bir kilit açma yöntemi kullanın." }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "Kilit açma için PRF uyumlu geçiş anahtarı bulunamadı." }, "additionalStorageGB": { "message": "Ek depolama alanı GB" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Kim görebilir" + }, + "specificPeople": { + "message": "Belirli kişiler" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "E-posta adreslerini virgülle ayırarak yazın." + }, + "emailPlaceholder": { + "message": "kullanici@bitwarden.com , kullanici@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Geçersiz Send parolası" } } diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 0e8190479eb..bfca185fa57 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Немає критичних програм із ризиком" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Управління доступом" }, @@ -250,6 +268,9 @@ "application": { "message": "Програма" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Ризиковані паролі" }, @@ -586,6 +607,9 @@ "email": { "message": "Е-пошта" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Телефон" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Ні" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Розташування" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Next Charge" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Plan" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Одна чи декілька політик організації не дозволяють вам експортувати особисте сховище." }, - "activateAutofill": { - "message": "Активувати автозаповнення" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Активувати автозаповнення під час завантаження сторінки в налаштуваннях розширення браузера для всіх наявних і нових учасників." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Скомпрометовані або ненадійні вебсайти можуть використати функцію автозаповнення під час завантаження сторінки для завдання шкоди." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Дізнатися більше про автозаповнення" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Оберіть тип SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden намагатиметься заявити домен 3 рази впродовж 72 годин. Якщо не вдасться заявити домен, перевірте DNS-запис у вашого провайдера й заявіть його вручну. Якщо домен не буде заявлено протягом 7 днів, його буде вилучено з вашої організації." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ не заявлено. Перевірте свої DNS-записи.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Заявлено" }, - "domainStatusUnderVerification": { - "message": "Проходить перевірку" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Claim a domain to own member accounts. The SSO identifier page will be skipped during login for members with claimed domains and administrators will be able to delete claimed accounts." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index c64c98d3453..142fb08f6ac 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "Không có ứng dụng quan trọng nào bị đe dọa" }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Trí tuệ truy cập" }, @@ -250,6 +268,9 @@ "application": { "message": "Ứng dụng" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "Mật khẩu có rủi ro cao" }, @@ -586,6 +607,9 @@ "email": { "message": "Email" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "Điện thoại" }, @@ -1365,6 +1389,12 @@ "no": { "message": "Không" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "Vị trí" }, @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "Lần thanh toán tiếp theo" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "Gói" }, @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "Các chính sách của tổ chức ngăn cản bạn xuất kho lưu trữ cá nhân của mình." }, - "activateAutofill": { - "message": "Bật tính năng tự động điền" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "Bật tính năng tự động điền khi tải trang trên tiện ích mở rộng trình duyệt cho tất cả thành viên hiện tại và mới." + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "Các trang web bị xâm phạm hoặc không đáng tin cậy có thể khai thác tính năng tự động điền khi tải trang." + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "Tìm hiểu thêm về tự động điền" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "Chọn loại SSO" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden sẽ thử xác nhận tên miền 3 lần trong 72 giờ đầu tiên. Nếu tên miền không thể được xác nhận, hãy kiểm tra bản ghi DNS trong máy chủ của bạn và tự xác nhận. Tên miền sẽ bị xóa khỏi tổ chức của bạn trong 7 ngày nếu không được xác nhận." }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "$DOMAIN$ chưa được xác nhận. Vui lòng kiểm tra bản ghi DNS của bạn.", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "Đã xác nhận" }, - "domainStatusUnderVerification": { - "message": "Đang trong quá trình xác minh" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "Xác nhận một tên miền để sở hữu tài khoản của thành viên. Trang định danh SSO sẽ bị bỏ qua trong quá trình đăng nhập cho các thành viên có tên miền đã xác nhận và quản trị viên sẽ có thể xóa các tài khoản đã xác nhận." @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "You have used all $GB$ GB of your encrypted storage. To continue storing files, add more storage." }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "Email protected" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index bf56f05c084..e1f52f5fdfe 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -14,6 +14,24 @@ "noCriticalAppsAtRisk": { "message": "没有关键应用程序存在风险" }, + "critical": { + "message": "关键 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "非关键 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } + }, "accessIntelligence": { "message": "Access Intelligence" }, @@ -250,6 +268,9 @@ "application": { "message": "应用程序" }, + "applications": { + "message": "应用程序" + }, "atRiskPasswords": { "message": "存在风险的密码" }, @@ -586,6 +607,9 @@ "email": { "message": "电子邮箱" }, + "emails": { + "message": "电子邮箱" + }, "phone": { "message": "电话" }, @@ -796,7 +820,7 @@ } }, "passwordSafe": { - "message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。" + "message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。" }, "save": { "message": "保存" @@ -1070,7 +1094,7 @@ "message": "其他" }, "share": { - "message": "分享" + "message": "共享" }, "moveToOrganization": { "message": "移动到组织" @@ -1365,6 +1389,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "拥有此链接的任何人" + }, + "anyOneWithPassword": { + "message": "拥有您设置的密码的任何人" + }, "location": { "message": "位置" }, @@ -2719,7 +2749,7 @@ "message": "YubiKey 已更新" }, "disableAllKeys": { - "message": "禁用全部密钥" + "message": "停用全部密钥" }, "twoFactorDuoDesc": { "message": "输入 Duo 管理面板提供的 Bitwarden 应用程序信息。" @@ -2782,7 +2812,7 @@ "message": "等待您触摸安全密钥上的按钮" }, "twoFactorU2fClickSave": { - "message": "单击下面的「保存」按钮,以启用此安全密钥用于两步登录。" + "message": "单击下面的「保存」按钮,以激活此安全密钥用于两步登录。" }, "twoFactorU2fProblemReadingTryAgain": { "message": "读取安全密钥时出现问题。请重试。" @@ -2791,7 +2821,7 @@ "message": "您的 Bitwarden 两步登录恢复代码" }, "twoFactorRecoveryNoCode": { - "message": "您尚未设置任何两步登录提供程序。在设置了一个两步登录提供程序后,请返回这里检查恢复代码。" + "message": "您尚未设置任何两步登录提供程序。设置两步登录提供程序后,返回这里查看您的恢复代码。" }, "printCode": { "message": "打印代码", @@ -2979,7 +3009,7 @@ "message": "检查泄漏情况" }, "breachUsernameNotFound": { - "message": "没有在已知的数据泄露中发现 $USERNAME$。", + "message": "在任何已知的数据泄露中均未发现 $USERNAME$。", "placeholders": { "username": { "content": "$1", @@ -2992,7 +3022,7 @@ "description": "ex. Good News, No Breached Accounts Found!" }, "breachUsernameFound": { - "message": "$USERNAME$ 在不同的在线数据泄漏中找到 $COUNT$ 次。", + "message": "在 $COUNT$ 个不同的在线数据泄露中发现了 $USERNAME$。", "placeholders": { "username": { "content": "$1", @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "下一次收费" }, + "nextChargeDate": { + "message": "下一次收费日期" + }, "plan": { "message": "方案" }, @@ -3491,7 +3524,7 @@ "description": "Free as in 'free beer'." }, "planDescFree": { - "message": "适用于测试或与 $COUNT$ 位其他用户共享的个人用户。", + "message": "适用于测试或个人用户与 $COUNT$ 位其他用户共享。", "placeholders": { "count": { "content": "$1", @@ -5627,7 +5660,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendCreatedDescriptionV2": { - "message": "复制并分享此 Send 链接。在接下来的 $TIME$ 内,任何人都可以通过链接访问此 Send。", + "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": { @@ -6925,16 +6958,16 @@ "personalVaultExportPolicyInEffect": { "message": "一个或多个组织策略阻止您导出个人密码库。" }, - "activateAutofill": { + "activateAutofillPolicy": { "message": "激活自动填充" }, - "activateAutofillPolicyDesc": { - "message": "为所有现有成员和新成员激活浏览器扩展上的页面加载时的自动填充设置。" + "activateAutofillPolicyDescription": { + "message": "为所有现有成员和新成员激活浏览器扩展上的页面加载时自动填充设置。" }, - "experimentalFeature": { - "message": "被入侵或不受信任的网站可能恶意利用页面加载时的自动填充功能。" + "autofillOnPageLoadExploitWarning": { + "message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。" }, - "learnMoreAboutAutofill": { + "learnMoreAboutAutofillPolicy": { "message": "进一步了解自动填充" }, "selectType": { @@ -7286,7 +7319,7 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Connect login with SSO to your self-hosted decryption key server. Using this option, members won’t need to use their master passwords to decrypt vault data. The require SSO authentication and single organization policies are required to set up Key Connector decryption. Contact Bitwarden Support for set up assistance.'" }, "keyConnectorPolicyRestriction": { - "message": "「SSO 登录和 Key Connector 解密」已启用。此策略仅适用于所有者和管理员。" + "message": "「SSO 登录和 Key Connector 解密」已激活。此策略仅适用于所有者和管理员。" }, "enabledSso": { "message": "SSO 已启用" @@ -7304,7 +7337,7 @@ } }, "enabledKeyConnector": { - "message": "Key Connector 已启用" + "message": "Key Connector 已激活" }, "disabledKeyConnector": { "message": "Key Connector 已停用" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden 将在最初的 72 小时内尝试声明域名 3 次。如果此域名无法声明,请检查您主机中的 DNS 记录并手动声明。如果此域名在 7 天内未声明,它将被从您的组织中移除。" }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden 将在 72 小时内尝试声明此域名。如果此域名无法声明,请验证您的 DNS 记录并手动声明。未声明的域名将在 7 天后被移除。" + }, + "automaticDomainClaimProcess2": { + "message": "声明后,使用已声明域名的现有成员将收到关于" + }, + "accountOwnershipChange": { + "message": "账户所有权变更" + }, + "automaticDomainClaimProcessEnd": { + "message": "的电子邮件通知。" + }, "domainNotClaimed": { "message": "$DOMAIN$ 无法声明。请检查您的 DNS 记录。", "placeholders": { @@ -11378,11 +11423,11 @@ "domainStatusClaimed": { "message": "已声明" }, - "domainStatusUnderVerification": { - "message": "验证中" + "domainStatusPending": { + "message": "处理中" }, "claimedDomainsDescription": { - "message": "声明域名以拥有成员账户。已声明域名的成员登录时将跳过 SSO 标识符页面,管理员也可以删除已声明的账户。" + "message": "声明域名以拥有成员账户。使用已声明域名的成员登录时将跳过 SSO 标识符页面,管理员也可以删除已声明的账户。" }, "invalidDomainNameClaimMessage": { "message": "输入的格式无效。格式:mydomain.com。子域名需要单独的条目进行声明。" @@ -12105,10 +12150,10 @@ "message": "使用通行密钥解锁" }, "prfUnlockFailed": { - "message": "Failed to unlock with passkey. Please try again or use another unlock method." + "message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。" }, "noPrfCredentialsAvailable": { - "message": "No PRF-enabled passkeys are available for unlock." + "message": "没有可用于解锁的 PRF 通行密钥。" }, "additionalStorageGB": { "message": "附加存储 GB" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "您已使用了全部的 $GB$ GB 加密存储空间。要继续存储文件,请添加更多存储空间。" }, + "whoCanView": { + "message": "谁可以查看" + }, + "specificPeople": { + "message": "指定的人员" + }, + "emailVerificationDesc": { + "message": "分享此 Send 链接后,个人需要使用验证码验证他们的电子邮箱才能查看此 Send。" + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "输入多个电子邮箱(使用英文逗号分隔)。" + }, + "emailPlaceholder": { + "message": "user@bitwarden.com, user@acme.com" + }, "whenYouRemoveStorage": { "message": "当您移除存储空间时,您将收到一笔按比例计算的账户信用额度,其将用于自动抵扣您的下一笔费用。" }, @@ -12683,6 +12743,9 @@ "message": "您拥有高级版" }, "emailProtected": { - "message": "Email protected" + "message": "电子邮箱保护" + }, + "invalidSendPassword": { + "message": "无效的 Send 密码" } } diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index 49a1455dbfd..90e4f214678 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -6,13 +6,31 @@ "message": "活動" }, "appLogoLabel": { - "message": "Bitwarden 圖示" + "message": "Bitwarden logo" }, "criticalApplications": { - "message": "重要應用程式" + "message": "關鍵應用程式" }, "noCriticalAppsAtRisk": { - "message": "目前沒有任何關鍵應用程式存在風險" + "message": "目前沒有關鍵應用程式存在風險" + }, + "critical": { + "message": "Critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "notCritical": { + "message": "Not critical ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "5" + } + } }, "accessIntelligence": { "message": "Access Intelligence" @@ -122,7 +140,7 @@ } }, "criticalApplicationsWithCount": { - "message": "重要應用程式($COUNT$)", + "message": "關鍵應用程式 ($COUNT$)", "placeholders": { "count": { "content": "$1", @@ -131,7 +149,7 @@ } }, "criticalApplicationsMarked": { - "message": "已標記為關鍵的應用程式" + "message": "已標記為關鍵應用程式" }, "countOfCriticalApplications": { "message": "$COUNT$ 個關鍵應用程式", @@ -188,7 +206,7 @@ "message": "將應用程式標記為關鍵" }, "feature1Description": { - "message": "這將協助您優先消除最重要應用程式的風險。" + "message": "這將協助您優先消除關鍵應用程式的風險。" }, "feature2Title": { "message": "協助成員提升其安全性" @@ -215,13 +233,13 @@ "message": "選擇您最關鍵的應用程式,以優先處理安全行動,讓使用者解決有風險的密碼。" }, "markCriticalApplications": { - "message": "選擇重要應用程式" + "message": "選擇關鍵應用程式" }, "markAppAsCritical": { - "message": "標註應用程式為重要" + "message": "將應用程式標記為關鍵" }, "markAsCritical": { - "message": "標註應用程式為重要" + "message": "標記為關鍵" }, "applicationsSelected": { "message": "已選擇的應用程式" @@ -233,7 +251,7 @@ "message": "取消選擇應用程式" }, "applicationsMarkedAsCriticalSuccess": { - "message": "被標註重要的應用程式" + "message": "已標記為關鍵的應用程式" }, "criticalApplicationsMarkedSuccess": { "message": "已將 $COUNT$ 個應用程式標記為關鍵", @@ -250,6 +268,9 @@ "application": { "message": "應用程式" }, + "applications": { + "message": "Applications" + }, "atRiskPasswords": { "message": "具有風險的密碼" }, @@ -302,7 +323,7 @@ } }, "atRiskMemberDescription": { - "message": "這些成員正以薄弱、已外洩或重複使用的密碼登入關鍵應用程式。" + "message": "這些成員正以強度不足、已外洩或重複使用的密碼登入關鍵應用程式。" }, "atRiskMembersDescriptionNone": { "message": "目前沒有成員使用弱密碼、外洩密碼或重複密碼登入應用程式。" @@ -377,7 +398,7 @@ } }, "reviewApplicationsToSecureItems": { - "message": "審查應用程式以保護對組織安全最重要的項目" + "message": "檢視應用程式,以確保對組織安全最關鍵的項目受到保護" }, "reviewApplications": { "message": "審核認領" @@ -477,7 +498,7 @@ "message": "身分" }, "contactInfo": { - "message": "聯繫資訊" + "message": "聯絡資訊" }, "cardDetails": { "message": "支付卡詳細資料" @@ -575,7 +596,7 @@ "message": "公司" }, "ssn": { - "message": "社會保險號碼" + "message": "社會安全號碼" }, "passportNumber": { "message": "護照號碼" @@ -586,6 +607,9 @@ "email": { "message": "電子郵件" }, + "emails": { + "message": "Emails" + }, "phone": { "message": "電話號碼" }, @@ -766,11 +790,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": { @@ -1365,6 +1389,12 @@ "no": { "message": "否" }, + "noAuth": { + "message": "Anyone with the link" + }, + "anyOneWithPassword": { + "message": "Anyone with a password set by you" + }, "location": { "message": "位置" }, @@ -1384,7 +1414,7 @@ "message": "使用主密碼登入" }, "readingPasskeyLoading": { - "message": "正在讀取通行金鑰..." + "message": "正在讀取密碼金鑰…" }, "readingPasskeyLoadingInfo": { "message": "保持此視窗打開,然後按照瀏覽器的提示進行操作。" @@ -1925,7 +1955,7 @@ } }, "deleteSelectedConfirmation": { - "message": "您確定要繼續嗎?" + "message": "您確定要繼續嗎?" }, "moveSelectedItemsDesc": { "message": "選擇要將這 $COUNT$ 個項目移動至哪個資料夾。", @@ -3153,7 +3183,7 @@ "message": "若要重新存取您的封存項目,請重新啟用進階版訂閱。若您在重新啟用前編輯封存項目的詳細資料,它將會被移回您的密碼庫。" }, "itemRestored": { - "message": "已還原項目" + "message": "項目已還原" }, "restartPremium": { "message": "重新啟用進階版" @@ -3165,7 +3195,7 @@ "message": "# GB 額外儲存空間" }, "additionalStorageIntervalDesc": { - "message": "您的方案擁有 $SIZE$ 的加密儲存空間。您也可以用每 GB $PRICE$ / $INTERVAL$ 購買額外的儲存空間。", + "message": "您的方案提供 $SIZE$ 的加密儲存空間。如需額外儲存空間,可依每 GB 每 $INTERVAL$ $PRICE$ 加購。", "placeholders": { "size": { "content": "$1", @@ -3252,19 +3282,19 @@ "message": "待取消" }, "subscriptionPendingCanceled": { - "message": "此訂閱在目前計費周期结束前已標記為取消。" + "message": "此訂閱已標記為將於目前計費週期結束時取消。" }, "reinstateSubscription": { "message": "恢復訂閱" }, "reinstateConfirmation": { - "message": "您是否要移除待處理的取消要求,重新開始您的訂閱?" + "message": "確定要撤回取消申請並恢復訂閱嗎?" }, "reinstated": { "message": "已重新開始訂閱。" }, "cancelConfirmation": { - "message": "您確定要取消訂閱嗎?在目前計費周期結束之後,您將無法使用所有訂閲功能。" + "message": "您確定要取消訂閱嗎?在本次計費週期結束後,您將無法再使用此訂閱的所有功能。" }, "canceledSubscription": { "message": "訂閱已取消" @@ -3281,6 +3311,9 @@ "nextChargeHeader": { "message": "下一次收費" }, + "nextChargeDate": { + "message": "Next charge date" + }, "plan": { "message": "方案" }, @@ -6095,13 +6128,13 @@ "message": "預設 URI 相符偵測" }, "uriMatchDetectionPolicyDesc": { - "message": "決定何時建議登入項目進行自動填入。管理員與擁有者不受此原則限制。" + "message": "用於決定何時提供登入項目的自動填入建議。系統管理員與擁有者不受此原則限制。" }, "uriMatchDetectionOptionsLabel": { "message": "預設 URI 相符偵測" }, "invalidUriMatchDefaultPolicySetting": { - "message": "請選擇有效的 URI 比對偵測選項。", + "message": "請選擇有效的 URI 相符偵測選項。", "description": "Error message displayed when a user attempts to save URI match detection policy settings with an invalid selection." }, "modifiedPolicyId": { @@ -6925,17 +6958,17 @@ "personalVaultExportPolicyInEffect": { "message": "一個或多個組織原則禁止您匯出個人密碼庫。" }, - "activateAutofill": { - "message": "啓用自動填入" + "activateAutofillPolicy": { + "message": "Activate autofill" }, - "activateAutofillPolicyDesc": { - "message": "為所有現有的和新的成員,啓用瀏覽器擴充套件上的頁面載入自動填入設定。" + "activateAutofillPolicyDescription": { + "message": "Activate the autofill on page load setting on the browser extension for all existing and new members." }, - "experimentalFeature": { - "message": "被入侵或不被信任的網站,可能會濫用頁面載入的自動填入功能。" + "autofillOnPageLoadExploitWarning": { + "message": "Compromised or untrusted websites can exploit autofill on page load." }, - "learnMoreAboutAutofill": { - "message": "進一步瞭解「自動填入」功能" + "learnMoreAboutAutofillPolicy": { + "message": "Learn more about autofill" }, "selectType": { "message": "選擇 SSO 類型" @@ -9912,7 +9945,7 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI 匹配偵測是 Bitwarden 用來識別自動填入建議的方式。", + "message": "URI 相符偵測是 Bitwarden 用來判定自動填入建議的方式。", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { @@ -9924,7 +9957,7 @@ "description": "Content for dialog which warns a user when selecting 'starts with' matching strategy as a cipher match strategy" }, "uriMatchWarningDialogLink": { - "message": "深入了解匹配偵測", + "message": "深入了解相符偵測", "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { @@ -10935,7 +10968,7 @@ "message": "深入瞭解緊急存取" }, "learnMoreAboutMatchDetection": { - "message": "深入瞭解符合項目偵測" + "message": "瞭解更多關於相符偵測的資訊" }, "learnMoreAboutMasterPasswordReprompt": { "message": "深入瞭解主密碼再次提示" @@ -11366,6 +11399,18 @@ "automaticDomainClaimProcess": { "message": "Bitwarden 會在前 72 小時內嘗試宣告該網域 3 次。若無法宣告,請檢查主機上的 DNS 紀錄並手動宣告。若 7 天內仍未宣告,該網域將自你的組織移除。" }, + "automaticDomainClaimProcess1": { + "message": "Bitwarden will attempt to claim the domain within 72 hours. If the domain can't be claimed, verify your DNS record and claim manually. Unclaimed domains are removed after 7 days." + }, + "automaticDomainClaimProcess2": { + "message": "Once claimed, existing members with claimed domains will be emailed about the " + }, + "accountOwnershipChange": { + "message": "account ownership change" + }, + "automaticDomainClaimProcessEnd": { + "message": "." + }, "domainNotClaimed": { "message": "尚未宣告 $DOMAIN$。請檢查你的 DNS 紀錄。", "placeholders": { @@ -11378,8 +11423,8 @@ "domainStatusClaimed": { "message": "已宣告" }, - "domainStatusUnderVerification": { - "message": "驗證中" + "domainStatusPending": { + "message": "Pending" }, "claimedDomainsDescription": { "message": "宣告網域以取得其成員帳號的管理權。擁有已宣告網域的成員在登入時會略過 SSO 識別頁面,且管理員將可刪除已宣告的帳號。" @@ -11882,11 +11927,11 @@ "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'For tips on getting started with Bitwarden visit the Learning Center and Help Center'" }, "openExtensionFromToolbarPart1": { - "message": "如果擴充套件沒有開啟,您可能需要從圖示開啟 Bitwarden ", + "message": "若擴充套件沒有開啟,您可能需要透過工具列上的圖示 ", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "openExtensionFromToolbarPart2": { - "message": " 在工具列上。", + "message": "來開啟 Bitwarden。", "description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'If the extension didn't open, you may need to open Bitwarden from the icon [Bitwarden Icon] on the toolbar.'" }, "gettingStartedWithBitwardenPart3": { @@ -12336,7 +12381,7 @@ "message": "在啟用此原則前,必須先宣告網域。" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "設定一個解鎖方式來變更您的密碼庫逾時動作。" + "message": "設定解鎖方式以變更您的密碼庫逾時行為。" }, "vaultTimeoutPolicyAffectingOptions": { "message": "企業政策已套用至您的逾時選項中" @@ -12504,7 +12549,7 @@ "message": "於瀏覽器重新整理時" }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { - "message": "設定一個解鎖方式來變更您的密碼庫逾時動作。" + "message": "設定解鎖方式,以變更逾時後的行為" }, "leaveConfirmationDialogTitle": { "message": "確定要離開嗎?" @@ -12676,6 +12721,21 @@ "storageFullDescription": { "message": "您已用完全部 $GB$ GB 的加密儲存空間。如需繼續儲存檔案,請增加儲存空間。" }, + "whoCanView": { + "message": "Who can view" + }, + "specificPeople": { + "message": "Specific people" + }, + "emailVerificationDesc": { + "message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send." + }, + "enterMultipleEmailsSeparatedByComma": { + "message": "Enter multiple emails by separating with a comma." + }, + "emailPlaceholder": { + "message": "user@bitwarden.com , user@acme.com" + }, "whenYouRemoveStorage": { "message": "當您移除儲存空間時,將會獲得按比例計算的帳戶抵扣金額,並自動套用至下一期帳單。" }, @@ -12684,5 +12744,8 @@ }, "emailProtected": { "message": "電子郵件已受保護" + }, + "invalidSendPassword": { + "message": "Invalid Send password" } } From 122203f5891a4f2612696c7b3da586064b01e486 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:58:19 +0000 Subject: [PATCH 093/130] Autosync the updated translations (#18671) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/id/messages.json | 38 +++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index ec51aa90d4f..3472d26cd01 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -228,7 +228,7 @@ "message": "Salin Nama Kolom Pilihan" }, "noMatchingLogins": { - "message": "Tidak ada info masuk yang cocok." + "message": "Tidak ada log masuk yang cocok" }, "noCards": { "message": "Tanpa kartu" @@ -440,7 +440,7 @@ "message": "Sinkronisasi" }, "syncNow": { - "message": "Sync now" + "message": "Selaraskan sekarang" }, "lastSync": { "message": "Sinkronisasi Terakhir:" @@ -554,27 +554,27 @@ "message": "Atur ulang pencarian" }, "archiveNoun": { - "message": "Archive", + "message": "Arsip", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Arsip", "description": "Verb" }, "unArchive": { "message": "Unarchive" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Butir dalam arsip" }, "noItemsInArchive": { - "message": "No items in archive" + "message": "Tidak ada butir dalam arsip" }, "noItemsInArchiveDesc": { - "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." + "message": "Butir yang diarsipkan akan muncul di sini dan akan dikecualikan dari hasil pencarian umum dan saran isi otomatis." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Butir dikirim ke arsip" }, "itemWasUnarchived": { "message": "Item was unarchived" @@ -583,7 +583,7 @@ "message": "Item was unarchived" }, "archiveItem": { - "message": "Archive item" + "message": "Arsipkan butir" }, "archiveItemDialogContent": { "message": "Once archived, this item will be excluded from search results and autofill suggestions." @@ -1486,7 +1486,7 @@ "message": "Tidak ada lampiran." }, "attachmentSaved": { - "message": "Lampiran telah disimpan." + "message": "Lampiran disimpan" }, "fixEncryption": { "message": "Fix encryption" @@ -1504,7 +1504,7 @@ "message": "Berkas untuk dibagikan" }, "selectFile": { - "message": "Pilih berkas." + "message": "Pilih berkas" }, "itemsTransferred": { "message": "Items transferred" @@ -1652,7 +1652,7 @@ "message": "Buka dalam tab baru" }, "webAuthnAuthenticate": { - "message": "Autentikasi dengan WebAuthn." + "message": "Autentikasikan WebAuthn" }, "readSecurityKey": { "message": "Baca kunci keamanan" @@ -1761,7 +1761,7 @@ "message": "URL Server Ikon" }, "environmentSaved": { - "message": "URL dari semua lingkungan telah disimpan." + "message": "URL lingkungan disimpan" }, "showAutoFillMenuOnFormFields": { "message": "Tampilkan menu isi otomatis pada kolom formulir", @@ -1861,7 +1861,7 @@ "message": "Pelajari lebih lanjut tentang isi otomatis" }, "defaultAutoFillOnPageLoad": { - "message": "Konfigurasi autofill standard untuk item login." + "message": "Pengaturan isian otomatis baku bagi butir log masuk" }, "defaultAutoFillOnPageLoadDesc": { "message": "Setelah mengaktifkan Auto-Fill waktu website terbuka, kamu dapat mengaktifkan atau meng-nonaktifkan feature ini untuk setiap item. Ini adalah konfigurasi standard untuk item yang tidak dikonfigurasi terpisah." @@ -1891,7 +1891,7 @@ "message": "Isi otomatis identitas yang terakhir digunakan untuk situs web saat ini" }, "commandGeneratePasswordDesc": { - "message": "Buat dan salin kata sandi acak baru ke papan klip." + "message": "Buat dan salin kata sandi acak baru ke papan klip" }, "commandLockVaultDesc": { "message": "Kunci brankas" @@ -2513,10 +2513,10 @@ "message": "Item yang Diisi Otomatis dan URI Tersimpan" }, "autoFillSuccess": { - "message": "Item Terisi Otomatis" + "message": "Butir terisi otomatis " }, "insecurePageWarning": { - "message": "Peringatan: Ini adalah halaman HTTP yang tidak aman, dan setiap informasi yang Anda kirim dapat berpotensi terlihat dan diubah oleh orang lain. Login ini awalnya disimpan di halaman aman (HTTPS) " + "message": "Peringatan: Ini adalah halaman HTTP yang tidak aman, dan setiap informasi yang Anda kirim dapat berpotensi terlihat dan diubah oleh orang lain. Log masuk ini awalnya disimpan di halaman aman (HTTPS)." }, "insecurePageWarningFillPrompt": { "message": "Anda masih ingin mengisi login ini?" @@ -3219,7 +3219,7 @@ "message": "Persyaratan kebijakan perusahaan telah diterapkan ke pilihan batas waktu Anda" }, "vaultTimeoutPolicyInEffect": { - "message": "Kebijakan organisasi Anda memengaruhi waktu tunggu brankas Anda. Batas maksimal Waktu Tunggu Brankas yang diizinkan adalah $HOURS$ jam dan $MINUTES$ menit", + "message": "Kebijakan organisasi Anda telah menata waktu tunggu brankas maksimum yang diizinkan milik Anda ke $HOURS$ jam $MINUTES$ menit.", "placeholders": { "hours": { "content": "$1", @@ -3311,7 +3311,7 @@ "message": "Hapus Kata Sandi Utama" }, "removedMasterPassword": { - "message": "Sandi utama dihapus." + "message": "Sandi utama dihapus" }, "leaveOrganizationConfirmation": { "message": "Apakah Anda yakin ingin meninggalkan organisasi ini?" From 93ce914f79eb77b5fc0dc7d7efb331126a8aace9 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:10:26 -0600 Subject: [PATCH 094/130] [PM-30638] Cipher Add/Edit dialog focus (#18536) * allow exporting of the DialogComponent * focus on dialog header when switching modes * update to view child fixmes --- .../vault-item-dialog.component.spec.ts | 74 ++++++++++++++++++- .../vault-item-dialog.component.ts | 23 +++--- .../src/dialog/dialog/dialog.component.ts | 31 +++++--- libs/components/src/dialog/index.ts | 1 + 4 files changed, 106 insertions(+), 23 deletions(-) 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 9a048b7a8b3..276c0c2e6a3 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 @@ -104,7 +104,7 @@ describe("VaultItemDialogComponent", () => { getFeatureFlag$: () => of(false), }, }, - { provide: Router, useValue: {} }, + { provide: Router, useValue: { navigate: jest.fn() } }, { provide: ActivatedRoute, useValue: {} }, { provide: BillingAccountProfileStateService, @@ -356,4 +356,76 @@ describe("VaultItemDialogComponent", () => { }); }); }); + + describe("changeMode", () => { + beforeEach(() => { + component.setTestCipher({ type: CipherType.Login, id: "cipher-id" }); + }); + + it("refocuses the dialog header", async () => { + const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader"); + + await component["changeMode"]("view"); + + expect(focusOnHeaderSpy).toHaveBeenCalled(); + }); + + describe("to view", () => { + beforeEach(() => { + component.setTestParams({ mode: "form" }); + fixture.detectChanges(); + }); + + it("sets mode to view", async () => { + await component["changeMode"]("view"); + + expect(component["params"].mode).toBe("view"); + }); + + it("updates the url", async () => { + const router = TestBed.inject(Router); + + await component["changeMode"]("view"); + + expect(router.navigate).toHaveBeenCalledWith([], { + queryParams: { action: "view", itemId: "cipher-id" }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + }); + }); + + describe("to form", () => { + const waitForFormReady = async () => { + const changeModePromise = component["changeMode"]("form"); + + expect(component["loadForm"]).toBe(true); + + component["onFormReady"](); + await changeModePromise; + }; + + beforeEach(() => { + component.setTestParams({ mode: "view" }); + fixture.detectChanges(); + }); + + it("waits for form to be ready when switching to form mode", async () => { + await waitForFormReady(); + + expect(component["params"].mode).toBe("form"); + }); + + it("updates the url", async () => { + const router = TestBed.inject(Router); + await waitForFormReady(); + + expect(router.navigate).toHaveBeenCalledWith([], { + queryParams: { action: "edit", itemId: "cipher-id" }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + }); + }); + }); }); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 5d5e319c8af..90452ba573a 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -8,7 +8,7 @@ import { Inject, OnDestroy, OnInit, - ViewChild, + viewChild, } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; @@ -50,6 +50,7 @@ import { ItemModule, ToastService, CenterPositionStrategy, + DialogComponent, } from "@bitwarden/components"; import { AttachmentDialogCloseResult, @@ -163,14 +164,11 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { * Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes. * @protected */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild("dialogContent") - protected dialogContent: ElementRef; + protected readonly dialogContent = viewChild.required>("dialogContent"); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent; + private readonly cipherFormComponent = viewChild.required(CipherFormComponent); + + private readonly dialogComponent = viewChild(DialogComponent); /** * Tracks if the cipher was ever modified while the dialog was open. Used to ensure the dialog emits the correct result @@ -536,7 +534,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { updatedCipherView = await this.cipherService.decrypt(updatedCipher, activeUserId); } - this.cipherFormComponent.patchCipher((currentCipher) => { + this.cipherFormComponent().patchCipher((currentCipher) => { currentCipher.attachments = updatedCipherView.attachments; currentCipher.revisionDate = updatedCipherView.revisionDate; @@ -574,7 +572,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return; } - this.cipherFormComponent.patchCipher((current) => { + this.cipherFormComponent().patchCipher((current) => { current.revisionDate = revisionDate; current.archivedDate = archivedDate; return current; @@ -691,7 +689,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.params.mode = mode; this.updateTitle(); // Scroll to the top of the dialog content when switching modes. - this.dialogContent.nativeElement.parentElement.scrollTop = 0; + this.dialogContent().nativeElement.parentElement.scrollTop = 0; + + // Refocus on title element, the built-in focus management of the dialog only works for the initial open. + this.dialogComponent().focusOnHeader(); // Update the URL query params to reflect the new mode. await this.router.navigate([], { diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index f9073da2217..2ce19a9f9e0 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -145,6 +145,26 @@ export class DialogComponent implements AfterViewInit { }); ngAfterViewInit() { + this.focusOnHeader(); + } + + handleEsc(event: Event) { + if (!this.dialogRef?.disableClose) { + this.dialogRef?.close(); + event.stopPropagation(); + } + } + + onAnimationEnd() { + this.animationCompleted.set(true); + } + + /** + * Moves focus to the dialog header element. + * This is done automatically when the dialog is opened but can be called manually + * when the contents of the dialog change and focus should be reset. + */ + focusOnHeader(): void { /** * Wait a tick for any focus management to occur on the trigger element before moving focus to * the dialog header. We choose the dialog header because it is always present, unlike possible @@ -159,15 +179,4 @@ export class DialogComponent implements AfterViewInit { this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout)); } - - handleEsc(event: Event) { - if (!this.dialogRef?.disableClose) { - this.dialogRef?.close(); - event.stopPropagation(); - } - } - - onAnimationEnd() { - this.animationCompleted.set(true); - } } diff --git a/libs/components/src/dialog/index.ts b/libs/components/src/dialog/index.ts index fb4c2721b81..ce41f7957f6 100644 --- a/libs/components/src/dialog/index.ts +++ b/libs/components/src/dialog/index.ts @@ -2,3 +2,4 @@ export * from "./dialog.module"; export * from "./simple-dialog/types"; export * from "./dialog.service"; export { DIALOG_DATA } from "@angular/cdk/dialog"; +export { DialogComponent } from "./dialog/dialog.component"; From 997956b1728ee2c1d0a8c0b6e1d758093f26441b Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Fri, 30 Jan 2026 10:18:58 -0800 Subject: [PATCH 095/130] [PM-19090] Add ssh key header title to emergency access view modal --- .../view/emergency-view-dialog.component.spec.ts | 8 ++++++++ .../view/emergency-view-dialog.component.ts | 3 +++ 2 files changed, 11 insertions(+) diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts index d13987f2e8b..fde0ea3a33f 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -166,5 +166,13 @@ describe("EmergencyViewDialogComponent", () => { expect(component["title"]).toBe("viewItemHeaderNote"); }); + + it("sets ssh key title", () => { + mockCipher.type = CipherType.SshKey; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemHeaderSshKey"); + }); }); }); diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index 62cfd95ecfa..86b75d27666 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -90,6 +90,9 @@ export class EmergencyViewDialogComponent { case CipherType.SecureNote: this.title = this.i18nService.t("viewItemHeaderNote"); break; + case CipherType.SshKey: + this.title = this.i18nService.t("viewItemHeaderSshKey"); + break; } } From 17f264701cbe9c0562ed89c07cb552c0ebadfcd4 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Fri, 30 Jan 2026 10:49:43 -0800 Subject: [PATCH 096/130] [PM-24183] Updates aria-haspopup to dialog --- .../components/vault-items/vault-cipher-row.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 081829a8d83..3e62ccfd21d 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -26,7 +26,7 @@ title="{{ 'editItemWithName' | i18n: cipher.name }}" type="button" appStopProp - aria-haspopup="true" + aria-haspopup="dialog" > {{ cipher.name }} From 7c4ea23f8863f9f346d0d0341609353a311496bf Mon Sep 17 00:00:00 2001 From: Will Martin Date: Fri, 30 Jan 2026 14:51:54 -0500 Subject: [PATCH 097/130] [CL-970] delete deprecated drawer (#18577) * delete bit drawer Co-Authored-By: Claude Sonnet 4.5 * fix: remove stale drawer export from components barrel file The drawer directory was deleted but the export statement in index.ts was not removed, causing import errors. Co-authored-by: Will Martin --------- Co-authored-by: Claude Sonnet 4.5 Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Will Martin --- libs/components/src/dialog/dialog.service.ts | 2 +- .../src/{drawer => dialog}/drawer.service.ts | 0 .../src/drawer/drawer-body.component.ts | 27 ---- .../src/drawer/drawer-close.directive.ts | 28 ---- .../src/drawer/drawer-header.component.html | 9 -- .../src/drawer/drawer-header.component.ts | 34 ----- .../src/drawer/drawer-host.directive.ts | 27 ---- .../src/drawer/drawer.component.html | 8 -- .../components/src/drawer/drawer.component.ts | 75 ---------- libs/components/src/drawer/drawer.mdx | 122 ----------------- libs/components/src/drawer/drawer.module.ts | 12 -- libs/components/src/drawer/drawer.stories.ts | 128 ------------------ libs/components/src/drawer/index.ts | 5 - libs/components/src/index.ts | 1 - .../components/src/layout/layout.component.ts | 4 +- .../kitchen-sink-shared.module.ts | 3 - 16 files changed, 2 insertions(+), 483 deletions(-) rename libs/components/src/{drawer => dialog}/drawer.service.ts (100%) delete mode 100644 libs/components/src/drawer/drawer-body.component.ts delete mode 100644 libs/components/src/drawer/drawer-close.directive.ts delete mode 100644 libs/components/src/drawer/drawer-header.component.html delete mode 100644 libs/components/src/drawer/drawer-header.component.ts delete mode 100644 libs/components/src/drawer/drawer-host.directive.ts delete mode 100644 libs/components/src/drawer/drawer.component.html delete mode 100644 libs/components/src/drawer/drawer.component.ts delete mode 100644 libs/components/src/drawer/drawer.mdx delete mode 100644 libs/components/src/drawer/drawer.module.ts delete mode 100644 libs/components/src/drawer/drawer.stories.ts delete mode 100644 libs/components/src/drawer/index.ts diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 8393db57b2f..ed17cb27327 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -16,9 +16,9 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { DrawerService } from "../drawer/drawer.service"; import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils"; +import { DrawerService } from "./drawer.service"; import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; import { SimpleDialogOptions } from "./simple-dialog/types"; diff --git a/libs/components/src/drawer/drawer.service.ts b/libs/components/src/dialog/drawer.service.ts similarity index 100% rename from libs/components/src/drawer/drawer.service.ts rename to libs/components/src/dialog/drawer.service.ts diff --git a/libs/components/src/drawer/drawer-body.component.ts b/libs/components/src/drawer/drawer-body.component.ts deleted file mode 100644 index c6499067642..00000000000 --- a/libs/components/src/drawer/drawer-body.component.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CdkScrollable } from "@angular/cdk/scrolling"; -import { ChangeDetectionStrategy, Component } from "@angular/core"; - -import { hasScrolledFrom } from "../utils/has-scrolled-from"; - -/** - * Body container for `bit-drawer` - */ -@Component({ - selector: "bit-drawer-body", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [], - host: { - class: - "tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", - "[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top", - }, - hostDirectives: [ - { - directive: CdkScrollable, - }, - ], - template: ` `, -}) -export class DrawerBodyComponent { - protected hasScrolledFrom = hasScrolledFrom(); -} diff --git a/libs/components/src/drawer/drawer-close.directive.ts b/libs/components/src/drawer/drawer-close.directive.ts deleted file mode 100644 index f105e21ea62..00000000000 --- a/libs/components/src/drawer/drawer-close.directive.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Directive, inject } from "@angular/core"; - -import { DrawerComponent } from "./drawer.component"; - -/** - * Closes the ancestor drawer - * - * @example - * - * ```html - * - * - * - * ``` - **/ -@Directive({ - selector: "button[bitDrawerClose]", - host: { - "(click)": "onClick()", - }, -}) -export class DrawerCloseDirective { - private drawer = inject(DrawerComponent, { optional: true }); - - protected onClick() { - this.drawer?.open.set(false); - } -} diff --git a/libs/components/src/drawer/drawer-header.component.html b/libs/components/src/drawer/drawer-header.component.html deleted file mode 100644 index 2723744eda3..00000000000 --- a/libs/components/src/drawer/drawer-header.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
    -
    - -

    - {{ title() }} -

    -
    - -
    diff --git a/libs/components/src/drawer/drawer-header.component.ts b/libs/components/src/drawer/drawer-header.component.ts deleted file mode 100644 index 006c48e091d..00000000000 --- a/libs/components/src/drawer/drawer-header.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { ChangeDetectionStrategy, Component, HostBinding, input } from "@angular/core"; - -import { I18nPipe } from "@bitwarden/ui-common"; - -import { IconButtonModule } from "../icon-button"; -import { TypographyModule } from "../typography"; - -import { DrawerCloseDirective } from "./drawer-close.directive"; - -/** - * Header container for `bit-drawer` - **/ -@Component({ - selector: "bit-drawer-header", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, DrawerCloseDirective, TypographyModule, IconButtonModule, I18nPipe], - templateUrl: "drawer-header.component.html", - host: { - class: "tw-block tw-ps-4 tw-pe-2 tw-py-2", - }, -}) -export class DrawerHeaderComponent { - /** - * The title to display - */ - readonly title = input.required(); - - /** We don't want to set the HTML title attribute with `this.title` */ - @HostBinding("attr.title") - protected get getTitle(): null { - return null; - } -} diff --git a/libs/components/src/drawer/drawer-host.directive.ts b/libs/components/src/drawer/drawer-host.directive.ts deleted file mode 100644 index 7804d111ed6..00000000000 --- a/libs/components/src/drawer/drawer-host.directive.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Portal } from "@angular/cdk/portal"; -import { Directive, signal } from "@angular/core"; - -/** - * Host that renders a drawer - * - * @internal - */ -@Directive({ - selector: "[bitDrawerHost]", -}) -export class DrawerHostDirective { - private readonly _portal = signal | undefined>(undefined); - - /** The portal to display */ - portal = this._portal.asReadonly(); - - open(portal: Portal) { - this._portal.set(portal); - } - - close(portal: Portal) { - if (portal === this.portal()) { - this._portal.set(undefined); - } - } -} diff --git a/libs/components/src/drawer/drawer.component.html b/libs/components/src/drawer/drawer.component.html deleted file mode 100644 index 79cbf319e7d..00000000000 --- a/libs/components/src/drawer/drawer.component.html +++ /dev/null @@ -1,8 +0,0 @@ - -
    - -
    -
    diff --git a/libs/components/src/drawer/drawer.component.ts b/libs/components/src/drawer/drawer.component.ts deleted file mode 100644 index 042d1eace79..00000000000 --- a/libs/components/src/drawer/drawer.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { CdkPortal, PortalModule } from "@angular/cdk/portal"; -import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - effect, - inject, - input, - model, - viewChild, -} from "@angular/core"; - -import { DrawerService } from "./drawer.service"; - -/** - * A drawer is a panel of supplementary content that is adjacent to the page's main content. - * - * Drawers render in `bit-layout`. Drawers must be a descendant of `bit-layout`, but they do not need to be a direct descendant. - */ -@Component({ - selector: "bit-drawer", - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, PortalModule], - templateUrl: "drawer.component.html", -}) -export class DrawerComponent { - private drawerHost = inject(DrawerService); - private readonly portal = viewChild.required(CdkPortal); - - /** - * Whether or not the drawer is open. - * - * Note: Does not support implicit boolean transform due to Angular limitation. Must be bound explicitly `[open]="true"` instead of just `open`. - * https://github.com/angular/angular/issues/55166#issuecomment-2032150999 - **/ - readonly open = model(false); - - /** - * The ARIA role of the drawer. - * - * - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role) - * - For drawers that contain content that is complementary to the page's main content. (default) - * - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role) - * - For drawers that primary contain links to other content. - */ - readonly role = input<"complementary" | "navigation">("complementary"); - - constructor() { - effect( - () => { - this.open() ? this.drawerHost.open(this.portal()) : this.drawerHost.close(this.portal()); - }, - { - allowSignalWrites: true, - }, - ); - - // Set `open` to `false` when another drawer is opened. - effect( - () => { - if (this.drawerHost.portal() !== this.portal()) { - this.open.set(false); - } - }, - { - allowSignalWrites: true, - }, - ); - } - - /** Toggle the drawer between open & closed */ - toggle() { - this.open.update((prev) => !prev); - } -} diff --git a/libs/components/src/drawer/drawer.mdx b/libs/components/src/drawer/drawer.mdx deleted file mode 100644 index 1050ab476f7..00000000000 --- a/libs/components/src/drawer/drawer.mdx +++ /dev/null @@ -1,122 +0,0 @@ -import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks"; - -import * as stories from "./drawer.stories"; - -import { DrawerOpen as KitchenSink } from "../stories/kitchen-sink/kitchen-sink.stories"; - - - -```ts -import { DrawerComponent } from "@bitwarden/components"; -``` - -# Drawer - -**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.** - -A drawer is a panel of supplementary content that is adjacent to the page's main content. - - - - - -## Usage - -A `bit-drawer` in a template will not render inline, but rather will render adjacent to the main -page content. - -```html - - - -

    Lorem ipsum dolor...

    -
    -
    -``` - -`bit-drawer` must be a descendant of `bit-layout`, but it does not need to be a direct descendant. - -## Header and body - -Header and body content can be provided with the `bit-drawer-header` and `bit-drawer-body` -components, respectively. - -A title can be passed to the header by input: -`` - -Custom content can be rendered before the title with the header's `start` slot: - -```html - - - -``` - -## Opening and closing - -`bit-drawer` opens when its `open` input is `true`: - -```html -... -``` - -Note: Model inputs do not support implicit boolean transformation (see Angular reasoning -[here](https://github.com/angular/angular/issues/55166#issuecomment-2032150999)). `open` must be -bound explicitly `` instead of just ``. - -Buttons can be made to open/toggle drawers by referencing a template variable, or by manipulating -state that is bound to `open`: - -```html - ... -``` - -For convenience, close buttons can be created _inside_ the drawer with the `bitDrawerClose` -directive: - -```html - - - -``` - -## Multiple Drawers - -Only one drawer can be open at a time, and they do not stack. If a drawer is already open, opening -another will close and replace the one already open. - - - -## Headless - -Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content. - - - -## Accessibility - -- The drawer should contain an h2 element. If you are using `bit-drawer-header`, this is created for - you via the `title` input: - -```html - -

    Hello world!

    -
    - - - - - - -``` - -- The ARIA role of the drawer can be set with the `role` attribute: - - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role) - (default) - - For drawers that contain content that is complementary to the page's main content. - - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role) - - For drawers that primary contain links to other content. - -## Kitchen Sink - - diff --git a/libs/components/src/drawer/drawer.module.ts b/libs/components/src/drawer/drawer.module.ts deleted file mode 100644 index 9f51ba06b4e..00000000000 --- a/libs/components/src/drawer/drawer.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { DrawerBodyComponent } from "./drawer-body.component"; -import { DrawerCloseDirective } from "./drawer-close.directive"; -import { DrawerHeaderComponent } from "./drawer-header.component"; -import { DrawerComponent } from "./drawer.component"; - -@NgModule({ - imports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective], - exports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective], -}) -export class DrawerModule {} diff --git a/libs/components/src/drawer/drawer.stories.ts b/libs/components/src/drawer/drawer.stories.ts deleted file mode 100644 index 9904b77ee9f..00000000000 --- a/libs/components/src/drawer/drawer.stories.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; - -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { GlobalStateProvider } from "@bitwarden/state"; - -import { ButtonModule } from "../button"; -import { CalloutModule } from "../callout"; -import { LayoutComponent } from "../layout"; -import { mockLayoutI18n } from "../layout/mocks"; -import { positionFixedWrapperDecorator } from "../stories/storybook-decorators"; -import { TypographyModule } from "../typography"; -import { I18nMockService, StorybookGlobalStateProvider } from "../utils"; - -import { DrawerBodyComponent } from "./drawer-body.component"; -import { DrawerHeaderComponent } from "./drawer-header.component"; -import { DrawerComponent } from "./drawer.component"; -import { DrawerModule } from "./drawer.module"; - -export default { - title: "Component Library/Drawer", - component: DrawerComponent, - subcomponents: { - DrawerHeaderComponent, - DrawerBodyComponent, - }, - decorators: [ - positionFixedWrapperDecorator(), - moduleMetadata({ - imports: [ - RouterTestingModule, - LayoutComponent, - DrawerModule, - ButtonModule, - CalloutModule, - TypographyModule, - ], - providers: [ - { - provide: I18nService, - useFactory: () => { - return new I18nMockService({ - ...mockLayoutI18n, - close: "Close", - loading: "Loading", - }); - }, - }, - ], - }), - applicationConfig({ - providers: [ - { - provide: GlobalStateProvider, - useClass: StorybookGlobalStateProvider, - }, - ], - }), - ], -} as Meta; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - -

    The drawer is {{ open ? "open" : "closed" }}.

    - - - - - - - - -

    - 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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -

    - -
    - - `, - }), - args: { - open: true, - }, -}; - -export const Headless: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - -

    The drawer is {{ open ? "open" : "closed" }}.

    - - -

    - Hello world! -
    - - `, - }), - args: { - open: true, - }, -}; - -export const MultipleDrawers: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - - - - - - Foo - - - - Bar - - - `, - }), -}; diff --git a/libs/components/src/drawer/index.ts b/libs/components/src/drawer/index.ts deleted file mode 100644 index abf5b8d34f1..00000000000 --- a/libs/components/src/drawer/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./drawer.module"; -export * from "./drawer.component"; -export * from "./drawer-body.component"; -export * from "./drawer-close.directive"; -export * from "./drawer-header.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 80fd6fc05a6..7395b87b2ab 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -17,7 +17,6 @@ export * from "./container"; export * from "./copy-click"; export * from "./dialog"; export * from "./disclosure"; -export * from "./drawer"; export * from "./form-field"; export * from "./header"; export * from "./icon-button"; diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 5e3d420c8e5..da30b76a9f0 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -4,8 +4,7 @@ import { CommonModule } from "@angular/common"; import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { DrawerHostDirective } from "../drawer/drawer-host.directive"; -import { DrawerService } from "../drawer/drawer.service"; +import { DrawerService } from "../dialog/drawer.service"; import { LinkModule } from "../link"; import { SideNavService } from "../navigation/side-nav.service"; import { SharedModule } from "../shared"; @@ -31,7 +30,6 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive"; "(document:keydown.tab)": "handleKeydown($event)", class: "tw-block tw-h-screen", }, - hostDirectives: [DrawerHostDirective], }) export class LayoutComponent { protected sideNavService = inject(SideNavService); diff --git a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts index 398251fd2e2..1b2c7cec5da 100644 --- a/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts +++ b/libs/components/src/stories/kitchen-sink/kitchen-sink-shared.module.ts @@ -13,7 +13,6 @@ import { CalloutModule } from "../../callout"; import { CheckboxModule } from "../../checkbox"; import { ColorPasswordModule } from "../../color-password"; import { DialogModule } from "../../dialog"; -import { DrawerModule } from "../../drawer"; import { FormControlModule } from "../../form-control"; import { FormFieldModule } from "../../form-field"; import { IconButtonModule } from "../../icon-button"; @@ -49,7 +48,6 @@ import { TypographyModule } from "../../typography"; ColorPasswordModule, CommonModule, DialogModule, - DrawerModule, FormControlModule, FormFieldModule, FormsModule, @@ -87,7 +85,6 @@ import { TypographyModule } from "../../typography"; ColorPasswordModule, CommonModule, DialogModule, - DrawerModule, FormControlModule, FormFieldModule, FormsModule, From 903acfa3dfd67861f9d9afa8732f3a785cbcf881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 30 Jan 2026 20:55:40 +0100 Subject: [PATCH 098/130] Don't make PRF available in any client that is not web/browser, even if it's lying about navigator.credentials (#18687) --- .../services/default-webauthn-prf-unlock.service.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 index 106037bc5f7..b3bbf392d0a 100644 --- 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 @@ -54,11 +54,12 @@ export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService 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() - ) { + // PRF unlock is only supported on Web and Chromium-based browser extensions + const clientType = this.platformUtilsService.getClientType(); + if (clientType === ClientType.Browser && !this.platformUtilsService.isChromium()) { + return false; + } + if (clientType !== ClientType.Web && clientType !== ClientType.Browser) { return false; } From d3aef2c14bff6758dc1ca1b0d1c47f708ddb03c8 Mon Sep 17 00:00:00 2001 From: Brad <44413459+lastbestdev@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:37:49 -0800 Subject: [PATCH 099/130] [PM-31385] Safari Report icon rendering fix #18641 * add full height tailwind class to report icons --- .../dirt/reports/shared/report-card/report-card.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index 6b201e7f6ae..ab0fe0c28ac 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -8,7 +8,7 @@ [ngClass]="{ 'tw-grayscale': disabled }" >
    - +
    From b667a84b44bcfb325df09c5dcdd07f3e4e823195 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:14:28 -0500 Subject: [PATCH 100/130] [deps]: Update actions/cache action to v5.0.2 (#18568) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/build-desktop.yml | 26 +++++++++++++------------- .github/workflows/chromatic.yml | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c021dedd8e1..6818064a808 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -236,7 +236,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -399,7 +399,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -562,7 +562,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -827,7 +827,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1032,14 +1032,14 @@ jobs: - name: Cache Build id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Cache Safari id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1185,7 +1185,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1272,14 +1272,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1409,7 +1409,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | @@ -1547,14 +1547,14 @@ jobs: - name: Get Build Cache id: build-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/desktop/build key: ${{ runner.os }}-${{ github.run_id }}-build - name: Setup Safari Cache id: safari-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: apps/browser/dist/Safari key: ${{ runner.os }}-${{ github.run_id }}-safari-extension @@ -1692,7 +1692,7 @@ jobs: npm link ../sdk-internal - name: Cache Native Module - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 id: cache with: path: | diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 6189744fe67..b1dc3165c3e 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -65,7 +65,7 @@ jobs: - name: Cache NPM id: npm-cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2 with: path: "~/.npm" key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }} From a1bf6afad68cfaac2604e3af182173e367121a83 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Fri, 30 Jan 2026 14:01:10 -0800 Subject: [PATCH 101/130] [PM-21564] Hide buttons when user has View access to an item * Changes attachment modal to remove choose file button and changes upload button to close button if the user doesn't have edit rights to the cipher. --- .../attachments-v2.component.spec.ts | 2 +- .../src/vault/app/vault/vault-v2.component.ts | 1 + .../vault/individual-vault/vault.component.ts | 1 + .../cipher-attachments.component.html | 102 +++++++++--------- .../cipher-attachments.component.spec.ts | 31 ++++++ .../cipher-attachments.component.ts | 10 +- .../attachments/attachments-v2.component.html | 3 +- .../attachments-v2.component.spec.ts | 8 ++ .../attachments/attachments-v2.component.ts | 14 +++ 9 files changed, 120 insertions(+), 52 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts index 1da2d352c14..d8f1d34ef9a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.spec.ts @@ -109,7 +109,7 @@ describe("AttachmentsV2Component", () => { }); it("passes the submit button to the cipher attachments component", () => { - const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[1] + const submitBtn = fixture.debugElement.queryAll(By.directive(ButtonComponent))[0] .componentInstance; expect(cipherAttachment.submitBtn()).toEqual(submitBtn); 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 fe2914216a3..458ddd666b8 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -518,6 +518,7 @@ export class VaultV2Component } const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: this.cipherId as CipherId, + canEditCipher: this.cipher().edit, }); const result = await firstValueFrom(dialogRef.closed).catch((): any => null); if ( 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 b07de88baf9..fe4b7f1f96f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -925,6 +925,7 @@ export class VaultComponent implements OnInit, OnDestr const dialogRef = AttachmentsV2Component.open(this.dialogService, { cipherId: cipher.id as CipherId, organizationId: cipher.organizationId as OrganizationId, + canEditCipher: cipher.edit, }); const result: AttachmentDialogCloseResult = await lastValueFrom(dialogRef.closed); diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html index 855c37ecab5..6aaaf033e0d 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.html @@ -38,14 +38,16 @@ } - - - + @if (cipher().edit) { + + + + } @@ -54,46 +56,48 @@ }
    - - -
    - - - - - -

    - {{ "maxFileSizeSansPunctuation" | i18n }} -

    - +

    + {{ "maxFileSizeSansPunctuation" | i18n }} +

    + + } diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts index 2e54d3b539a..002ad019653 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.spec.ts @@ -51,6 +51,7 @@ describe("CipherAttachmentsComponent", () => { username: "username", password: "password", }, + edit: true, } as CipherView; const cipherDomain = { @@ -197,6 +198,10 @@ describe("CipherAttachmentsComponent", () => { let file: File; beforeEach(() => { + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + submitBtnFixture.componentInstance.disabled.set(undefined as unknown as boolean); file = new File([""], "attachment.txt", { type: "text/plain" }); @@ -371,6 +376,32 @@ describe("CipherAttachmentsComponent", () => { expect(emitSpy).toHaveBeenCalled(); }); }); + + describe("close", () => { + async function setup(): Promise { + fixture = TestBed.createComponent(CipherAttachmentsComponent); + component = fixture.componentInstance; + submitBtnFixture = TestBed.createComponent(ButtonComponent); + + // Set organizationId BEFORE cipherId so the effect picks it up + fixture.componentRef.setInput("organizationId", organization.id); + fixture.componentRef.setInput("submitBtn", submitBtnFixture.componentInstance); + fixture.componentRef.setInput("cipherId", "5555-444-3333" as CipherId); + await waitForInitialization(); + const nonEditableCipherView = { ...cipherView, edit: false }; + cipherServiceDecrypt.mockResolvedValue(nonEditableCipherView); + fixture.detectChanges(); + } + + it('emits "onCloseButtonPress"', async () => { + await setup(); + const emitSpy = jest.spyOn(component.onCloseButtonPress, "emit"); + + await component.submit(); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); }); describe("removeAttachment", () => { diff --git a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts index f75611b995e..e0a648e3107 100644 --- a/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts +++ b/libs/vault/src/cipher-form/components/attachments/cipher-attachments.component.ts @@ -105,6 +105,8 @@ export class CipherAttachmentsComponent { /** Emits after a file has been successfully removed */ readonly onRemoveSuccess = output(); + readonly onCloseButtonPress = output(); + protected readonly organization = signal(null); protected readonly cipher = signal(null); @@ -154,7 +156,7 @@ export class CipherAttachmentsComponent { // Update the initial state of the submit button const btn = this.submitBtn(); if (btn) { - btn.disabled.set(!this.attachmentForm.valid); + btn.disabled.set(!this.attachmentForm.valid && (this.cipher()?.edit ?? true)); } }); @@ -192,6 +194,12 @@ export class CipherAttachmentsComponent { /** Save the attachments to the cipher */ submit = async () => { + //user can't edit cipher and will close the bit-dialog + if (!(this.cipher()?.edit ?? false)) { + this.onCloseButtonPress.emit(); + return; + } + this.onUploadStarted.emit(); const file = this.attachmentForm.value.file; diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html index a8dc22c75ac..964fba6a266 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html @@ -13,11 +13,12 @@ (onUploadSuccess)="uploadSuccessful()" (onUploadFailed)="uploadFailed()" (onRemoveSuccess)="removalSuccessful()" + (onCloseButtonPress)="closeButtonPressed()" > diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts index a188d673601..03ddb386ad0 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts @@ -69,4 +69,12 @@ describe("AttachmentsV2Component", () => { expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Removed }); }); + + it("closes the dialog with 'closed' result on closedButtonPressed", () => { + const dialogRefCloseSpy = jest.spyOn(component["dialogRef"], "close"); + + component.closeButtonPressed(); + + expect(dialogRefCloseSpy).toHaveBeenCalledWith({ action: AttachmentDialogResult.Closed }); + }); }); diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index 218f5b2c6d3..9810aa929d6 100644 --- a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from "@angular/common"; import { Component, HostListener, Inject } from "@angular/core"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { @@ -18,6 +19,7 @@ import { CipherAttachmentsComponent } from "../../cipher-form/components/attachm export interface AttachmentsDialogParams { cipherId: CipherId; + canEditCipher?: boolean; admin?: boolean; organizationId?: OrganizationId; } @@ -51,7 +53,9 @@ export class AttachmentsV2Component { cipherId: CipherId; admin: boolean = false; organizationId?: OrganizationId; + canEditCipher: boolean; attachmentFormId = CipherAttachmentsComponent.attachmentFormID; + buttonText: string; private isUploading = false; /** @@ -62,10 +66,14 @@ export class AttachmentsV2Component { constructor( private dialogRef: DialogRef, @Inject(DIALOG_DATA) public params: AttachmentsDialogParams, + private i18nService: I18nService, ) { this.cipherId = params.cipherId; this.organizationId = params.organizationId; this.admin = params.admin ?? false; + this.canEditCipher = params?.canEditCipher ?? false; + this.buttonText = + this.canEditCipher || this.admin ? this.i18nService.t("upload") : this.i18nService.t("close"); } /** @@ -140,4 +148,10 @@ export class AttachmentsV2Component { action: AttachmentDialogResult.Removed, }); } + + closeButtonPressed() { + this.dialogRef.close({ + action: AttachmentDialogResult.Closed, + }); + } } From 4a45414f4aaab20fccc97c29afb35e7c7ee76b31 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:16:32 -0500 Subject: [PATCH 102/130] [PM-30563] Improve Send Access enumeration protection (#18620) * feat: sync changes with SDK and server * Update libs/common/src/auth/send-access/types/invalid-request-errors.type.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * feat: sync changes with SDK and Server projects sync: sdk version * chore: update sdk * chore: update sdk * chore: prettier --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .../send/send-access/send-auth.component.ts | 5 ++--- .../default-send-token.service.spec.ts | 3 +-- .../types/invalid-grant-errors.type.ts | 7 ------- .../types/invalid-request-errors.type.ts | 12 ++++-------- package-lock.json | 18 ++++++++---------- package.json | 4 ++-- 6 files changed, 17 insertions(+), 32 deletions(-) diff --git a/apps/web/src/app/tools/send/send-access/send-auth.component.ts b/apps/web/src/app/tools/send/send-access/send-auth.component.ts index 13e82bd4cfa..9ed8106ad40 100644 --- a/apps/web/src/app/tools/send/send-access/send-auth.component.ts +++ b/apps/web/src/app/tools/send/send-access/send-auth.component.ts @@ -3,8 +3,7 @@ import { FormBuilder } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { - emailAndOtpRequiredEmailSent, - emailInvalid, + emailAndOtpRequired, emailRequired, otpInvalid, passwordHashB64Invalid, @@ -161,7 +160,7 @@ export class SendAuthComponent implements OnInit { this.expiredAuthAttempts = 0; if (emailRequired(response.error)) { this.sendAuthType.set(AuthType.Email); - } else if (emailAndOtpRequiredEmailSent(response.error) || emailInvalid(response.error)) { + } else if (emailAndOtpRequired(response.error)) { this.enterOtp.set(true); } else if (otpInvalid(response.error)) { this.toastService.showToast({ diff --git a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts index 8db0532911f..1145abc2a76 100644 --- a/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts +++ b/libs/common/src/auth/send-access/services/default-send-token.service.spec.ts @@ -64,14 +64,13 @@ describe("SendTokenService", () => { "send_id_required", "password_hash_b64_required", "email_required", - "email_and_otp_required_otp_sent", + "email_and_otp_required", "unknown", ]; const INVALID_GRANT_CODES: SendAccessTokenInvalidGrantError[] = [ "send_id_invalid", "password_hash_b64_invalid", - "email_invalid", "otp_invalid", "otp_generation_failed", "unknown", diff --git a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts index befb869a89e..e9c7e80406e 100644 --- a/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts +++ b/libs/common/src/auth/send-access/types/invalid-grant-errors.type.ts @@ -31,13 +31,6 @@ export function passwordHashB64Invalid( return e.error === "invalid_grant" && e.send_access_error_type === "password_hash_b64_invalid"; } -export type EmailInvalid = InvalidGrant & { - send_access_error_type: "email_invalid"; -}; -export function emailInvalid(e: SendAccessTokenApiErrorResponse): e is EmailInvalid { - return e.error === "invalid_grant" && e.send_access_error_type === "email_invalid"; -} - export type OtpInvalid = InvalidGrant & { send_access_error_type: "otp_invalid"; }; diff --git a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts index 57a70e62586..3e76a8f61f6 100644 --- a/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts +++ b/libs/common/src/auth/send-access/types/invalid-request-errors.type.ts @@ -39,16 +39,12 @@ export function emailRequired(e: SendAccessTokenApiErrorResponse): e is EmailReq return e.error === "invalid_request" && e.send_access_error_type === "email_required"; } -export type EmailAndOtpRequiredEmailSent = InvalidRequest & { - send_access_error_type: "email_and_otp_required_otp_sent"; +export type EmailAndOtpRequired = InvalidRequest & { + send_access_error_type: "email_and_otp_required"; }; -export function emailAndOtpRequiredEmailSent( - e: SendAccessTokenApiErrorResponse, -): e is EmailAndOtpRequiredEmailSent { - return ( - e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required_otp_sent" - ); +export function emailAndOtpRequired(e: SendAccessTokenApiErrorResponse): e is EmailAndOtpRequired { + return e.error === "invalid_request" && e.send_access_error_type === "email_and_otp_required"; } export type UnknownInvalidRequest = InvalidRequest & { diff --git a/package-lock.json b/package-lock.json index 59bd89afce4..da9b3e7dcbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@angular/platform-browser": "20.3.16", "@angular/platform-browser-dynamic": "20.3.16", "@angular/router": "20.3.16", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", - "@bitwarden/sdk-internal": "0.2.0-main.470", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.506", + "@bitwarden/sdk-internal": "0.2.0-main.506", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", @@ -4982,10 +4982,9 @@ "link": true }, "node_modules/@bitwarden/commercial-sdk-internal": { - "version": "0.2.0-main.470", - "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.470.tgz", - "integrity": "sha512-QYhxv5eX6ouFJv94gMtBW7MjuK6t2KAN9FLz+/w1wnq8dScnA9Iky25phNPw+iHMgWwhq/dzZq45asKUFF//oA==", - "license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT", + "version": "0.2.0-main.506", + "resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.506.tgz", + "integrity": "sha512-aRzcxOcj8vXxz0jN3q2xxj26zxBfjg3oRm5QXbWE7zXJ2PGrgxTaePca9pQYYpwgr7iufYMnZcq5dH+qttNEmA==", "dependencies": { "type-fest": "^4.41.0" } @@ -5087,10 +5086,9 @@ "link": true }, "node_modules/@bitwarden/sdk-internal": { - "version": "0.2.0-main.470", - "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.470.tgz", - "integrity": "sha512-XKvcUtoU6NnxeEzl3WK7bATiCh2RNxRmuX6JYNgcQHUtHUH+x3ckToR6II1qM3nha0VH0u1ijy3+07UdNQM+JQ==", - "license": "GPL-3.0", + "version": "0.2.0-main.506", + "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.506.tgz", + "integrity": "sha512-BbTSU5Acx74Hr32zDj2kV8sbdclyvdIti5t6kXnCvJmA5dZbu+5j5Xw1luS9mGL9Vfi4w3OjVug/TiSxyhwLzQ==", "dependencies": { "type-fest": "^4.41.0" } diff --git a/package.json b/package.json index 1cc4cabbceb..20ca9b20f8e 100644 --- a/package.json +++ b/package.json @@ -162,8 +162,8 @@ "@angular/platform-browser": "20.3.16", "@angular/platform-browser-dynamic": "20.3.16", "@angular/router": "20.3.16", - "@bitwarden/sdk-internal": "0.2.0-main.470", - "@bitwarden/commercial-sdk-internal": "0.2.0-main.470", + "@bitwarden/commercial-sdk-internal": "0.2.0-main.506", + "@bitwarden/sdk-internal": "0.2.0-main.506", "@electron/fuses": "1.8.0", "@emotion/css": "11.13.5", "@koa/multer": "4.0.0", From 1f0e0ca0980858f04eeb64aae04c2ffdd43517ad Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:11:59 -0800 Subject: [PATCH 103/130] refactor(input-password-flows): [Auth/PM-27086] JIT MP org user flow - remove masterKey generation from InputPasswordComponent (#18006) - Updates `InputPasswordComponent` to emit raw data instead of generating cryptographic properties (`newMasterKey`, `newServerMasterKeyHash`, `newLocalMasterKeyHash`). - This helps us in moving away from using the deprecated `makeMasterKey()` method in the component (which takes email as salt) as we seek to eventually separate the email from the salt. - Updates the `JIT_PROVISIONED_MP_ORG_USER` case of the switch to handle the flow when the `PM27086_UpdateAuthenticationApisForInputPassword` flag is on. Feature Flag: `PM27086_UpdateAuthenticationApisForInputPassword` --- ...sktop-set-initial-password.service.spec.ts | 6 ++ .../desktop-set-initial-password.service.ts | 3 + .../web-set-initial-password.service.spec.ts | 6 ++ .../web-set-initial-password.service.ts | 3 + ...initial-password.service.implementation.ts | 9 +++ ...fault-set-initial-password.service.spec.ts | 4 + .../set-initial-password.component.ts | 64 ++++++++++++++- ...et-initial-password.service.abstraction.ts | 2 + .../input-password.component.ts | 46 ++++++++++- .../angular/input-password/input-password.mdx | 81 +++++++------------ .../input-password/input-password.stories.ts | 8 ++ .../input-password/password-input-result.ts | 14 ++++ libs/common/src/enums/feature-flag.enum.ts | 2 + 13 files changed, 189 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts index 9bb7d5077cf..430870a247b 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.spec.ts @@ -87,6 +87,10 @@ describe("DesktopSetInitialPasswordService", () => { expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; @@ -116,6 +120,8 @@ describe("DesktopSetInitialPasswordService", () => { orgSsoIdentifier: "orgSsoIdentifier", orgId: "orgId", resetPasswordAutoEnroll: false, + newPassword: "Test@Password123!", + salt: "user@example.com" as MasterPasswordSalt, }; userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; diff --git a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts index f9fb8361056..3b1562075f9 100644 --- a/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts +++ b/apps/desktop/src/app/services/set-initial-password/desktop-set-initial-password.service.ts @@ -54,6 +54,9 @@ export class DesktopSetInitialPasswordService ); } + /** + * @deprecated To be removed in PM-28143 + */ override async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts index b09b5f0bc9a..ead40739a50 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.spec.ts @@ -90,6 +90,10 @@ describe("WebSetInitialPasswordService", () => { expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; @@ -119,6 +123,8 @@ describe("WebSetInitialPasswordService", () => { orgSsoIdentifier: "orgSsoIdentifier", orgId: "orgId", resetPasswordAutoEnroll: false, + newPassword: "Test@Password123!", + salt: "user@example.com" as MasterPasswordSalt, }; userId = "userId" as UserId; userType = SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER; diff --git a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts index 0b8dba6c40e..a6a902ab847 100644 --- a/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts +++ b/apps/web/src/app/auth/core/services/password-management/set-initial-password/web-set-initial-password.service.ts @@ -56,6 +56,9 @@ export class WebSetInitialPasswordService ); } + /** + * @deprecated To be removed in PM-28143 + */ override async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts index 3f6023c1205..85177a920e3 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -63,6 +63,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi protected registerSdkService: RegisterSdkService, ) {} + /** + * @deprecated To be removed in PM-28143. When you remove this, also check for any objects/methods + * in this default service that are now un-used and can also be removed. + */ async setInitialPassword( credentials: SetInitialPasswordCredentials, userType: SetInitialPasswordUserType, @@ -333,6 +337,9 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi ); } + /** + * @deprecated To be removed in PM-28143 + */ private async makeMasterKeyEncryptedUserKey( masterKey: MasterKey, userId: UserId, @@ -410,6 +417,8 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi } /** + * @deprecated To be removed in PM-28143 + * * As part of [PM-28494], adding this setting path to accommodate the changes that are * emerging with pm-23246-unlock-with-master-password-unlock-data. * Without this, immediately locking/unlocking the vault with the new password _may_ still fail diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts index 6b3981a5231..91578fdd7b6 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.spec.ts @@ -124,6 +124,10 @@ describe("DefaultSetInitialPasswordService", () => { expect(sut).not.toBeFalsy(); }); + /** + * @deprecated To be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ describe("setInitialPassword(...)", () => { // Mock function parameters let credentials: SetInitialPasswordCredentials; diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts index 4ab26ecd09e..7850a980eef 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.component.ts @@ -29,6 +29,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { @@ -38,6 +39,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -76,6 +78,7 @@ export class SetInitialPasswordComponent implements OnInit { private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, private dialogService: DialogService, private i18nService: I18nService, + private keyService: KeyService, private logoutService: LogoutService, private logService: LogService, private masterPasswordService: InternalMasterPasswordServiceAbstraction, @@ -110,16 +113,72 @@ export class SetInitialPasswordComponent implements OnInit { switch (this.userType) { case SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER: { + /** + * "KM flag" = EnableAccountEncryptionV2JitPasswordRegistration + * "Auth flag" = PM27086_UpdateAuthenticationApisForInputPassword (checked in InputPasswordComponent and + * passed through via PasswordInputResult) + * + * Flag unwinding for this specific `case` will depend on which flag gets unwound first: + * - If KM flag gets unwound first, remove all code (in this `case`) after the call + * to setInitialPasswordJitMPUserV2Encryption(), as the V2Encryption method is the + * end-goal for this `case`. + * - If Auth flag gets unwound first (in PM-28143), keep the KM code & early return, + * but unwind the auth flagging logic and then remove the method call marked with + * the "Default Scenario" comment. + */ + const accountEncryptionV2 = await this.configService.getFeatureFlag( FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration, ); + // Scenario 1: KM flag ON if (accountEncryptionV2) { await this.setInitialPasswordJitMPUserV2Encryption(passwordInputResult); return; } - await this.setInitialPassword(passwordInputResult); + // Scenario 2: KM flag OFF, Auth flag ON + if (passwordInputResult.newApisWithInputPasswordFlagEnabled) { + /** + * If the Auth flag is enabled, it means the InputPasswordComponent will not emit a newMasterKey, + * newServerMasterKeyHash, and newLocalMasterKeyHash. So we must create them here and add them late + * to the PasswordInputResult before calling setInitialPassword(). + * + * This is a temporary state. The end-goal will be to use KM's V2Encryption method above. + */ + const ctx = "Could not set initial password."; + assertTruthy(passwordInputResult.newPassword, "newPassword", ctx); + assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx); + assertTruthy(this.email, "email", ctx); + + const newMasterKey = await this.keyService.makeMasterKey( + passwordInputResult.newPassword, + this.email.trim().toLowerCase(), + passwordInputResult.kdfConfig, + ); + + const newServerMasterKeyHash = await this.keyService.hashMasterKey( + passwordInputResult.newPassword, + newMasterKey, + HashPurpose.ServerAuthorization, + ); + + const newLocalMasterKeyHash = await this.keyService.hashMasterKey( + passwordInputResult.newPassword, + newMasterKey, + HashPurpose.LocalAuthorization, + ); + + passwordInputResult.newMasterKey = newMasterKey; + passwordInputResult.newServerMasterKeyHash = newServerMasterKeyHash; + passwordInputResult.newLocalMasterKeyHash = newLocalMasterKeyHash; + + await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the SetInitialPasswordComponent (just above) + return; + } + + // Default Scenario: both flags OFF + await this.setInitialPassword(passwordInputResult); // passwordInputResult masterKey properties generated on the InputPasswordComponent (default) break; } @@ -274,6 +333,9 @@ export class SetInitialPasswordComponent implements OnInit { } } + /** + * @deprecated To be removed in PM-28143 + */ private async setInitialPassword(passwordInputResult: PasswordInputResult) { const ctx = "Could not set initial password."; assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx); diff --git a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts index 2667040c707..70318be3393 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/set-initial-password.service.abstraction.ts @@ -87,6 +87,8 @@ export interface InitializeJitPasswordCredentials { */ export abstract class SetInitialPasswordService { /** + * @deprecated To be removed in PM-28143 + * * Sets an initial password for an existing authed user who is either: * - {@link SetInitialPasswordUserType.JIT_PROVISIONED_MP_ORG_USER} * - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP} diff --git a/libs/auth/src/angular/input-password/input-password.component.ts b/libs/auth/src/angular/input-password/input-password.component.ts index 62294f037a0..b81e01156f1 100644 --- a/libs/auth/src/angular/input-password/input-password.component.ts +++ b/libs/auth/src/angular/input-password/input-password.component.ts @@ -10,7 +10,9 @@ import { import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; @@ -209,6 +211,7 @@ export class InputPasswordComponent implements OnInit { constructor( private auditService: AuditService, private cipherService: CipherService, + private configService: ConfigService, private dialogService: DialogService, private formBuilder: FormBuilder, private i18nService: I18nService, @@ -312,7 +315,7 @@ export class InputPasswordComponent implements OnInit { } if (!this.email) { - throw new Error("Email is required to create master key."); + throw new Error("Email not found."); } // 1. Determine kdfConfig @@ -320,13 +323,13 @@ export class InputPasswordComponent implements OnInit { this.kdfConfig = DEFAULT_KDF_CONFIG; } else { if (!this.userId) { - throw new Error("userId not passed down"); + throw new Error("userId not found."); } this.kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId)); } if (this.kdfConfig == null) { - throw new Error("KdfConfig is required to create master key."); + throw new Error("KdfConfig not found."); } const salt = @@ -334,7 +337,7 @@ export class InputPasswordComponent implements OnInit { ? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId)) : this.masterPasswordService.emailToSalt(this.email); if (salt == null) { - throw new Error("Salt is required to create master key."); + throw new Error("Salt not found."); } // 2. Verify current password is correct (if necessary) @@ -361,6 +364,41 @@ export class InputPasswordComponent implements OnInit { return; } + // When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used. + const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword, + ); + + if (newApisWithInputPasswordFlagEnabled) { + // 4. Build a PasswordInputResult object + const passwordInputResult: PasswordInputResult = { + newPassword, + kdfConfig: this.kdfConfig, + salt, + newPasswordHint, + newApisWithInputPasswordFlagEnabled, // To be removed in PM-28143 + }; + + if ( + this.flow === InputPasswordFlow.ChangePassword || + this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation + ) { + passwordInputResult.currentPassword = currentPassword; + } + + if (this.flow === InputPasswordFlow.ChangePasswordWithOptionalUserKeyRotation) { + passwordInputResult.rotateUserKey = this.formGroup.controls.rotateUserKey?.value; + } + + // 5. Emit and return PasswordInputResult object + this.onPasswordFormSubmit.emit(passwordInputResult); + return passwordInputResult; + } + + /******************************************************************* + * The following code (within this `try`) to be removed in PM-28143 + *******************************************************************/ + // 4. Create cryptographic keys and build a PasswordInputResult object const newMasterKey = await this.keyService.makeMasterKey( newPassword, diff --git a/libs/auth/src/angular/input-password/input-password.mdx b/libs/auth/src/angular/input-password/input-password.mdx index e3cdcbf08b9..4b174658d16 100644 --- a/libs/auth/src/angular/input-password/input-password.mdx +++ b/libs/auth/src/angular/input-password/input-password.mdx @@ -6,14 +6,12 @@ import * as stories from "./input-password.stories.ts"; # InputPassword Component -The `InputPasswordComponent` allows a user to enter master password related credentials. -Specifically, it does the following: +The `InputPasswordComponent` allows a user to enter a new master password for the purpose of setting +an initial password or changing an existing password. Specifically, it does the following: 1. Displays form fields in the UI 2. Validates form fields -3. Generates cryptographic properties based on the form inputs (e.g. `newMasterKey`, - `newServerMasterKeyHash`, etc.) -4. Emits the generated properties to the parent component +3. Emits values to the parent component The `InputPasswordComponent` is central to our set/change password flows, allowing us to keep our form UI and validation logic consistent. As such, it is intended for re-use in different set/change @@ -30,7 +28,6 @@ those values as needed. - [The InputPasswordFlow](#the-inputpasswordflow) - [Use Cases](#use-cases) - [HTML - Form Fields](#html---form-fields) - - [TypeScript - Credential Generation](#typescript---credential-generation) - [Difference between SetInitialPasswordAccountRegistration and SetInitialPasswordAuthedUser](#difference-between-setinitialpasswordaccountregistration-and-setinitialpasswordautheduser) - [Validation](#validation) - [Submit Logic](#submit-logic) @@ -44,20 +41,20 @@ those values as needed. **Required** - `flow` - the parent component must provide an `InputPasswordFlow`, which is used to determine - which form input elements will be displayed in the UI and which cryptographic keys will be created - and emitted. [Click here](#the-inputpasswordflow) to learn more about the different - `InputPasswordFlow` options. + which form input elements will be displayed in the UI and which values will be emitted. + [Click here](#the-inputpasswordflow) to learn more about the different `InputPasswordFlow` + options. **Optional (sometimes)** -These two `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` -are not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that -the `email` and/or `userId` is present in certain flows, while not present in other flows. +These `@Inputs` are optional on some flows, but required on others. Therefore these `@Inputs` are +not marked as `{ required: true }`, but there _is_ component logic that ensures (requires) that the +`email` and/or `userId` is present in certain flows, while not present in other flows. -- `email` - allows the `InputPasswordComponent` to generate a master key +- `email` - allows the `InputPasswordComponent` to use the email as a salt (if needed) - `userId` - allows the `InputPasswordComponent` to do things like get the user's `kdfConfig`, - verify that a current password is correct, and perform validation prior to user key rotation on - the parent + verify that a current password is correct, and perform validation prior to user key rotation (if + selected) on the parent **Optional** @@ -87,8 +84,7 @@ These `@Inputs` are truly optional. ## The `InputPasswordFlow` The `InputPasswordFlow` is a crucial and required `@Input` that influences both the HTML and the -credential generation logic of the component. It is important for the dev to understand when to use -each flow. +logic of the component. It is important for the dev to understand when to use each flow. ### Use Cases @@ -106,8 +102,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti - A "just-in-time" (JIT) provisioned user joins a master password (MP) encryption org and must set their initial password -- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with a - starting role that requires them to have/set their initial password +- A "just-in-time" (JIT) provisioned user joins a trusted device encryption (TDE) org with the reset + password permission ("manage account recovery") from the start, which requires them to have/set + their initial password - A note on JIT provisioned user flows: - Even though a JIT provisioned user is a brand-new user who was “just” created, we consider them to be an “existing authed user” _from the perspective of the set-password flow_. This is @@ -117,8 +114,9 @@ Used in scenarios where we do have an existing and authed user, and thus an acti registration when a user reaches the `/finish-signup` or `/trial-initiation` page to set their initial password, their account does not yet exist in the database, and will only be created once they set an initial password. -- An existing user in a TDE org logs in after the org admin upgraded the user to a role that now - requires them to have/set their initial password +- An existing user in a TDE org logs in after an org admin upgraded the user to have the reset + password persmission ("manage account recovery"), which now requires the user to have/set their + initial password - An existing user logs in after their org admin offboarded the org from TDE, and the user must now have/set their initial password

    @@ -126,7 +124,7 @@ Used in scenarios where we do have an existing and authed user, and thus an acti Used in scenarios where we simply want to offer the user the ability to change their password: -- User clicks an org email invite link an logs in with their password which does not meet the org's +- User clicks an org email invite link and logs in with their password which does not meet the org's policy requirements - User logs in with password that does not meet the org's policy requirements - User logs in after their password was reset via Account Recovery (and now they must change their @@ -156,26 +154,10 @@ which form field UI elements get displayed.
    -### TypeScript - Credential Generation - -- **`SetInitialPasswordAccountRegistration`** and **`SetInitialPasswordAuthedUser`** - - These flows involve a user setting their password for the first time. Therefore on submit the - component will only generate new credentials (`newMasterKey`) and not current credentials - (`currentMasterKey`).

    -- **`ChangePassword`** and **`ChangePasswordWithOptionalUserKeyRotation`** - - These flows both require the user to enter a current password along with a new password. - Therefore on submit the component will generate current credentials (`currentMasterKey`) along - with new credentials (`newMasterKey`).

    -- **`ChangePasswordDelegation`** - - This flow does not generate any credentials, but simply validates the new password and emits it - up to the parent. - -
    - ### Difference between `SetInitialPasswordAccountRegistration` and `SetInitialPasswordAuthedUser` -These two flows are similar in that they display the same form fields and only generate new -credentials, but we need to keep them separate for the following reasons: +These two flows are similar in that they display the same form fields, but we need to keep them +separate for the following reasons: - `SetInitialPasswordAccountRegistration` involves scenarios where we have no existing user, and **thus NO active account `userId`**: @@ -183,7 +165,7 @@ credentials, but we need to keep them separate for the following reasons: and **thus an active account `userId`**: The presence or absence of an active account `userId` is important because it determines how we get -the correct `kdfConfig` prior to key generation: +the correct `kdfConfig`: - If there is no `userId` passed down from the parent, we default to `DEFAULT_KDF_CONFIG` - If there is a `userId` passed down from the parent, we get the `kdfConfig` from state using the @@ -223,25 +205,16 @@ When the form is submitted, the `InputPasswordComponent` does the following in o checkbox) - Checks that the new password adheres to any enforced master password policies that were optionally passed down by the parent -2. Uses the form inputs to create cryptographic properties (`newMasterKey`, - `newServerMasterKeyHash`, etc.) -3. Emits those cryptographic properties up to the parent (along with other values defined in - `PasswordInputResult`) to be used by the parent as needed. +2. Emits values up to the parent (along with other values defined in `PasswordInputResult`) to be + used by the parent as needed. ```typescript export interface PasswordInputResult { currentPassword?: string; - currentMasterKey?: MasterKey; - currentServerMasterKeyHash?: string; - currentLocalMasterKeyHash?: string; - newPassword: string; - newPasswordHint?: string; - newMasterKey?: MasterKey; - newServerMasterKeyHash?: string; - newLocalMasterKeyHash?: string; - kdfConfig?: KdfConfig; + salt?: MasterPasswordSalt; + newPasswordHint?: string; rotateUserKey?: boolean; } ``` diff --git a/libs/auth/src/angular/input-password/input-password.stories.ts b/libs/auth/src/angular/input-password/input-password.stories.ts index 285ce94b269..9e3a6419d2a 100644 --- a/libs/auth/src/angular/input-password/input-password.stories.ts +++ b/libs/auth/src/angular/input-password/input-password.stories.ts @@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -59,6 +60,13 @@ export default { getAllDecrypted: () => Promise.resolve([]), }, }, + // Can remove ConfigService from component and stories in PM-28143 (if it is no longer used) + { + provide: ConfigService, + useValue: { + getFeatureFlag: () => false, // default to false since flag does not effect UI + }, + }, { provide: KdfConfigService, useValue: { diff --git a/libs/auth/src/angular/input-password/password-input-result.ts b/libs/auth/src/angular/input-password/password-input-result.ts index 11c8f0d274d..575302a9ee0 100644 --- a/libs/auth/src/angular/input-password/password-input-result.ts +++ b/libs/auth/src/angular/input-password/password-input-result.ts @@ -10,6 +10,20 @@ export interface PasswordInputResult { newPasswordHint?: string; rotateUserKey?: boolean; + /** + * Temporary property that persists the flag state through the entire set/change password process. + * This allows flows to consume this value instead of re-checking the flag state via ConfigService themselves. + * + * The ChangePasswordDelegation flows (Emergency Access Takeover and Account Recovery), however, only ever + * require a raw newPassword from the InputPasswordComponent regardless of whether the flag is on or off. + * Flagging for those 2 flows will be done via the ConfigService in their respective services. + * + * To be removed in PM-28143 + */ + newApisWithInputPasswordFlagEnabled?: boolean; + + // The deprecated properties below will be removed in PM-28143: https://bitwarden.atlassian.net/browse/PM-28143 + /** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */ currentMasterKey?: MasterKey; /** @deprecated */ diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9d9de56c608..dc960baae1d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -18,6 +18,7 @@ export enum FeatureFlag { /* Auth */ PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin", + PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password", SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ @@ -137,6 +138,7 @@ export const DefaultFeatureFlagValue = { /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, + [FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE, [FeatureFlag.SafariAccountSwitching]: FALSE, /* Billing */ From b5c3735808993433ae48efa3d96532bf32284030 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Sun, 1 Feb 2026 16:06:10 +0100 Subject: [PATCH 104/130] Revert "[deps] KM: Update Rust crate rsa to v0.9.10 [SECURITY] (#18220)" (#18693) This reverts commit bea6fb26f87e815062465646ab8ee6d0ca583fe0. --- apps/desktop/desktop_native/Cargo.lock | 9 +++++---- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 5ff105a70c3..2a30f98dc36 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -2115,10 +2115,11 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.6" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" dependencies = [ + "byteorder", "lazy_static", "libm", "num-integer", @@ -2805,9 +2806,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.10" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ "const-oid", "digest", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 6bddb234f45..f63b09de7ff 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -50,7 +50,7 @@ oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.2" -rsa = "=0.9.10" +rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" secmem-proc = "=0.3.7" From 590bec21663f8c480b619c094f7676cfdbc37f61 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 2 Feb 2026 14:35:49 +0100 Subject: [PATCH 105/130] Fix rsa signing and add unit tests (#18702) * Fix rsa signing and add unit tests * Fix sorting * Fix sorting --- apps/desktop/desktop_native/Cargo.lock | 1 + apps/desktop/desktop_native/core/Cargo.toml | 4 + .../desktop_native/core/src/ssh_agent/mod.rs | 125 ++++++++++++++++++ 3 files changed, 130 insertions(+) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 2a30f98dc36..6dab7721f6d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -918,6 +918,7 @@ dependencies = [ "oo7", "pin-project", "rand 0.9.2", + "rsa", "scopeguard", "secmem-proc", "security-framework", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index aa5d564c9e5..6dfe0487ed0 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -31,6 +31,7 @@ futures = { workspace = true } interprocess = { workspace = true, features = ["tokio"] } memsec = { workspace = true, features = ["alloc_ext"] } rand = { workspace = true } +rsa = "=0.9.6" sha2 = { workspace = true } ssh-key = { workspace = true, features = [ "encryption", @@ -85,5 +86,8 @@ windows = { workspace = true, features = [ ], optional = true } windows-future = { workspace = true } +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + [lints] workspace = true diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 8ba64618ffa..a8938acb992 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -307,3 +307,128 @@ fn parse_key_safe(pem: &str) -> Result Err(anyhow::Error::msg(format!("Failed to parse key: {e}"))), } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use ssh_key::Signature; + + use super::*; + + // Test Ed25519 key (unencrypted OpenSSH format) + const TEST_ED25519_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9gAAAJj79ujB+/bo +wQAAAAtzc2gtZWQyNTUxOQAAACAOYor3+kyAsXYs2sGikmUuhpxmVf2hAGd2TK7KwN4N9g +AAAEAgAQkLDKjON00XO+Y09BoIBuQsAXAx6HUhQoTEodVzig5iivf6TICxdizawaKSZS6G +nGZV/aEAZ3ZMrsrA3g32AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY-----"; + + // Test RSA 2048-bit key (unencrypted OpenSSH format) + const TEST_RSA_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn +NhAAAAAwEAAQAAAQEAy0YUFvgBLMZXIKjsBfcdO6N2Kk2VmjSpxa2aFD1TrAcVyyIZ9v8o +slQITyFL4GCK5VCJX9bqXBwc9ml8G/zt21ue6nadeZLhp2iXeQ+VUxmola9HhaFvxSNqi0 +MOJaWIfmisH4jt7Msdv4jwlDE5AkHAFig8wiwDgvSV3kmfhyPs38aq8Pa+wT3zBneGXT17 +34OhH4nicuq+L0GcR9BJQ5+jXNQIgGdqd7sKa8JchPXLXAbTug2SfwRmKgiCM0L6JQ5NSQ +FdRHW/iz4ARacSkHP3w0pH6ZtAd8+glzvZn1KcXwrN/CYl3fqFwiwcQXIF0KDoOI/UyiKZ +uDE+DW5M1wAAA8g2Sf0XNkn9FwAAAAdzc2gtcnNhAAABAQDLRhQW+AEsxlcgqOwF9x07o3 +YqTZWaNKnFrZoUPVOsBxXLIhn2/yiyVAhPIUvgYIrlUIlf1upcHBz2aXwb/O3bW57qdp15 +kuGnaJd5D5VTGaiVr0eFoW/FI2qLQw4lpYh+aKwfiO3syx2/iPCUMTkCQcAWKDzCLAOC9J +XeSZ+HI+zfxqrw9r7BPfMGd4ZdPXvfg6EfieJy6r4vQZxH0ElDn6Nc1AiAZ2p3uwprwlyE +9ctcBtO6DZJ/BGYqCIIzQvolDk1JAV1Edb+LPgBFpxKQc/fDSkfpm0B3z6CXO9mfUpxfCs +38JiXd+oXCLBxBcgXQoOg4j9TKIpm4MT4NbkzXAAAAAwEAAQAAAQB9HWssIAYJGyNxlMeB +fHJfzOLkctCME7ITXCEkKAMiNVIyr5CvuKnB6XsbyXC8cG/NaV7EwLGLdDpXaOHdEDcO9z +u/MLcIp2GA+x2QhAjzFy3uw+4P0CfNfVkM0n8YqOR0edTHrC5Vu0daJt19OTbPrsyeVrHf +Cdw3dHfyU/p+4IMP9NRA5ZSmYuOacC7ZoZU7xeVBpeZ4KEzrO98iIWtscncaQv4AcaAehL +VpvZWG1QmRhdbooU2ce5KH3aFKiyszcMGPMzn4aTZS14ycLFzmrMSa+nYf+nHXmyR5KmBd +A5P6ZLtcpT1xw6CC/ItRsdD7E67bugG38lgQpzloHAsRAAAAgBVKGMFi+lP+HKYdSzPAQN +n3HxVuuZ5VIjM6Rq2SxfdyGKj5PH4+ofNGBrF5j1du1oqfPypMM/B75bkBNOlzn6TQcgyX +YlsVOF31aE1hRg8eN1BH2bc1DC43MyTHgunAFzIYfs1hbX8i+cMybzXSTDsIc/xvQHkJ2w +TrPuz7+MATAAAAgQDk6e4ywxrINaOcuDKmRQxTs7rlkJk/tX59OkkqD/gYLMBRMfeKeuFD +Y8M1f5vlDkGFD/Jy0RtTfEJh02VjKTrszaaGCDFHe9tt6DAHY457tzr856zsq5hKDFEU0+ +jd+yE8QaloegGrcpujrxHnrpZx/7mA2qjQxLveHyCGWH3Q2wAAAIEA41N7DKxeb0doXai7 +Sl8+RpZBoyCyNkexWKHAeATKb4abd+k5/EEoLAb6aKaGMzMPm+s82l0lozVreKvHdAdZsY +fq1lhaVvnRWZhN/DXf7Akgicrg/TLqHH9w6db0Vg5A+zHmbkUzZ4A30CYIgn4vzVv5YIq3 +CmfliIQWtUylhrUAAAAQdGVzdEBleGFtcGxlLmNvbQECAw== +-----END OPENSSH PRIVATE KEY-----"; + + fn create_test_agent() -> ( + BitwardenDesktopAgent, + tokio::sync::mpsc::Receiver, + tokio::sync::broadcast::Sender<(u32, bool)>, + ) { + let (request_tx, request_rx) = tokio::sync::mpsc::channel::(16); + let (response_tx, response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(16); + let response_rx = Arc::new(Mutex::new(response_rx)); + + let agent = BitwardenDesktopAgent::new(request_tx, response_rx); + (agent, request_rx, response_tx) + } + + #[tokio::test] + async fn test_agent_sign_with_ed25519_key() { + let (mut agent, _request_rx, _response_tx) = create_test_agent(); + agent.is_running.store(true, Ordering::Relaxed); + + let keys = vec![( + TEST_ED25519_KEY.to_string(), + "ed25519-key".to_string(), + "ed25519-uuid".to_string(), + )]; + agent.set_keys(keys).expect("set_keys should succeed"); + + let keystore = agent.keystore.0.read().expect("RwLock is not poisoned"); + assert_eq!(keystore.len(), 1); + let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key"); + + // Verify the key metadata + assert_eq!(ssh_key.name, "ed25519-key"); + assert_eq!(ssh_key.cipher_uuid, "ed25519-uuid"); + + // Verify the key can sign data + let signing_key = ssh_key.private_key().expect("should have signing key"); + let message = b"test message for ed25519"; + let signature: Signature = signing_key.try_sign(message).expect("signing should work"); + + // Verify signature is non-empty and has expected algorithm + assert!(!signature.as_bytes().is_empty()); + assert_eq!(signature.algorithm(), ssh_key::Algorithm::Ed25519); + } + + #[tokio::test] + async fn test_agent_sign_with_rsa_key() { + let (mut agent, _request_rx, _response_tx) = create_test_agent(); + agent.is_running.store(true, Ordering::Relaxed); + + let keys = vec![( + TEST_RSA_KEY.to_string(), + "rsa-key".to_string(), + "rsa-uuid".to_string(), + )]; + agent.set_keys(keys).expect("set_keys should succeed"); + + let keystore = agent.keystore.0.read().expect("RwLock is not poisoned"); + assert_eq!(keystore.len(), 1); + let (_pub_bytes, ssh_key) = keystore.iter().next().expect("should have one key"); + + // Verify the key metadata + assert_eq!(ssh_key.name, "rsa-key"); + assert_eq!(ssh_key.cipher_uuid, "rsa-uuid"); + + // Verify the key can sign data + let signing_key = ssh_key.private_key().expect("should have signing key"); + let message = b"test message for rsa"; + let signature: Signature = signing_key.try_sign(message).expect("signing should work"); + + // Verify signature is non-empty and has expected algorithm + assert!(!signature.as_bytes().is_empty()); + assert_eq!( + signature.algorithm(), + ssh_key::Algorithm::Rsa { + hash: Some(ssh_key::HashAlg::Sha512) + } + ); + } +} From 2c30fb72bae09dfc1df452bac5d215802479af7f Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 2 Feb 2026 10:05:02 -0600 Subject: [PATCH 106/130] [PM-30544] Added Critical app badge to Access Intelligence (#18658) --- apps/web/src/locales/en/messages.json | 3 ++ .../applications.component.html | 2 +- ...pp-table-row-scrollable-m11.component.html | 49 ++++++++----------- .../app-table-row-scrollable-m11.component.ts | 3 -- 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 872509a81c2..d3b975e5834 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -32,6 +32,9 @@ } } }, + "criticalBadge":{ + "message": "Critical" + }, "accessIntelligence": { "message": "Access Intelligence" }, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html index 1bfe41901c8..7c4f6d04a6b 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/applications.component.html @@ -19,6 +19,7 @@ [ngModel]="selectedFilter()" (ngModelChange)="setFilterApplicationsByStatus($event)" fullWidth="false" + class="tw-min-w-48" > @if (!decryptionFailure) { - + - + 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 7a6c1db8026..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 @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { booleanAttribute, Component, input, Input } from "@angular/core"; +import { booleanAttribute, Component, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs"; import { filter } from "rxjs/operators"; @@ -76,10 +76,22 @@ export class ItemMoreOptionsComponent { } /** - * Flag to show the autofill menu options. Used for items that are + * Flag to show view item menu option. Used when something else is + * assigned as the primary action for the item, such as autofill. + */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ transform: booleanAttribute }) + showViewOption = false; + + /** + * Flag to hide the autofill menu options. Used for items that are * already in the autofill list suggestion. */ - readonly showAutofill = input(false, { transform: booleanAttribute }); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ transform: booleanAttribute }) + hideAutofillOptions = false; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index d3bc025905e..3dac158b8e1 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -90,11 +90,11 @@ - + - + diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts index 99338ddbb7c..22ad8dc40db 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/organization-filter.component.ts @@ -5,6 +5,7 @@ import { Component } from "@angular/core"; import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component"; import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; @@ -50,4 +51,15 @@ export class OrganizationFilterComponent extends BaseOrganizationFilterComponent }); } } + + getIconString(organization: Organization): string { + if ( + organization?.productTierType === ProductTierType.Free || + organization?.productTierType === ProductTierType.Families + ) { + return "bwi-family"; + } else { + return "bwi-business"; + } + } } diff --git a/libs/vault/src/services/vault-filter.service.ts b/libs/vault/src/services/vault-filter.service.ts index b21e140e023..445764827eb 100644 --- a/libs/vault/src/services/vault-filter.service.ts +++ b/libs/vault/src/services/vault-filter.service.ts @@ -26,6 +26,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { cloneCollection } from "@bitwarden/common/admin-console/utils/collection-utils"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -184,7 +185,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { const orgNodes: TreeNode[] = []; orgs.forEach((org) => { const orgCopy = org as OrganizationFilter; - orgCopy.icon = "bwi-business"; + if ( + org?.productTierType === ProductTierType.Free || + org?.productTierType === ProductTierType.Families + ) { + orgCopy.icon = "bwi-family"; + } else { + orgCopy.icon = "bwi-business"; + } const node = new TreeNode(orgCopy, headNode, orgCopy.name); orgNodes.push(node); }); From 5a397fb44e4a7fbd503e39fbea963b353521e21a Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 2 Feb 2026 15:01:24 -0500 Subject: [PATCH 113/130] [PM-29236] Refactor of post-submit notification triggering logic (#18395) * refactor triggerChangedPasswordNotification logic * improve triggerChangedPasswordNotification and test coverage to handle scenarios more comprehensively * restore triggerChangedPasswordNotification logic and move new logic and testing to triggerCipherNotification * add branching qualification logic for cipher notifications * add and implement undetermined-cipher-scenario-logic feature flag * add optional chaining to username comparison of existing login ciphers * cleanup * update tests * prefer explicit length comparisons --- .../overlay-notifications.background.ts | 17 + .../notification.background.spec.ts | 1513 ++++++++++++++++- .../background/notification.background.ts | 457 ++++- .../overlay-notifications.background.spec.ts | 128 ++ .../overlay-notifications.background.ts | 76 +- .../abstractions/notification-bar.ts | 4 + libs/common/src/enums/feature-flag.enum.ts | 2 + 7 files changed, 2153 insertions(+), 44 deletions(-) diff --git a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts index a70ffe25310..ae5026c9566 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay-notifications.background.ts @@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SecurityTask } from "@bitwarden/common/vault/tasks"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { NotificationTypes } from "../../notification/abstractions/notification-bar"; export type NotificationTypeData = { isVaultLocked?: boolean; @@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = { uri: ModifyLoginCipherFormData["uri"]; }; +/** + * Distinguished from `NotificationTypes` in that this represents the + * pre-resolved notification scenario, vs the notification component + * (e.g. "Add" and "Change" will be removed + * post-`useUndeterminedCipherScenarioTriggeringLogic` migration) + */ +export const NotificationScenarios = { + ...NotificationTypes, + /** represents scenarios handling saving new and updated ciphers after form submit */ + Cipher: "cipher", +} as const; + +export type NotificationScenario = + (typeof NotificationScenarios)[keyof typeof NotificationScenarios]; + export type WebsiteOriginsWithFields = Map>; export type ActiveFormSubmissionRequests = Set; +/** This type represents an expectation of nullish values being represented as empty strings */ export type ModifyLoginCipherFormData = { uri: string; username: string; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index a927c75dba0..0be6e5c0ac1 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -67,8 +67,10 @@ describe("NotificationBackground", () => { }); const folderService = mock(); const enableChangedPasswordPromptMock$ = new BehaviorSubject(true); + const enableAddedLoginPromptMock$ = new BehaviorSubject(true); const userNotificationSettingsService = mock(); userNotificationSettingsService.enableChangedPasswordPrompt$ = enableChangedPasswordPromptMock$; + userNotificationSettingsService.enableAddedLoginPrompt$ = enableAddedLoginPromptMock$; const domainSettingsService = mock(); const environmentService = mock(); @@ -90,7 +92,9 @@ describe("NotificationBackground", () => { }); beforeEach(() => { - activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Locked); + activeAccountStatusMock$ = new BehaviorSubject( + AuthenticationStatus.Locked as AuthenticationStatus, + ); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; accountService.activeAccount$ = activeAccountSubject; @@ -290,7 +294,7 @@ describe("NotificationBackground", () => { username: "test", password: "password", uri: "https://example.com", - newPassword: null, + newPassword: "", }; beforeEach(() => { tab = createChromeTabMock(); @@ -323,7 +327,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "", }; - activeAccountStatusMock$.next(AuthenticationStatus.Locked); + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); await notificationBackground.triggerAddLoginNotification(data, tab); @@ -389,14 +393,14 @@ describe("NotificationBackground", () => { password: data.password, }, sender.tab, - true, + true, // will yield an unlock followed by a new password notification ); }); it("adds the login to the queue if the user has an unlocked account and the login is new", async () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, - username: null, + username: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); @@ -426,8 +430,8 @@ describe("NotificationBackground", () => { let pushChangePasswordToQueueSpy: jest.SpyInstance; let getAllDecryptedForUrlSpy: jest.SpyInstance; const mockModifyLoginCipherFormData: ModifyLoginCipherFormData = { - username: null, - uri: null, + username: "", + uri: "", password: "currentPassword", newPassword: "newPassword", }; @@ -527,7 +531,7 @@ describe("NotificationBackground", () => { ...mockModifyLoginCipherFormData, uri: "https://example.com", password: "newPasswordUpdatedElsewhere", - newPassword: null, + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -589,7 +593,7 @@ describe("NotificationBackground", () => { "example.com", data?.newPassword, sender.tab, - true, + true, // will yield an unlock followed by an update password notification ); }); @@ -597,8 +601,8 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, - newPassword: null, + password: "", + newPassword: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -637,7 +641,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -665,7 +669,7 @@ describe("NotificationBackground", () => { const data: ModifyLoginCipherFormData = { ...mockModifyLoginCipherFormData, uri: "https://example.com", - password: null, + password: "", }; activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); getAllDecryptedForUrlSpy.mockResolvedValueOnce([ @@ -686,6 +690,1489 @@ describe("NotificationBackground", () => { }); }); + describe("triggerCipherNotification message handler", () => { + let tab: chrome.tabs.Tab; + let sender: chrome.runtime.MessageSender; + let getEnableChangedPasswordPromptSpy: jest.SpyInstance; + let getEnableAddedLoginPromptSpy: jest.SpyInstance; + let pushChangePasswordToQueueSpy: jest.SpyInstance; + let pushAddLoginToQueueSpy: jest.SpyInstance; + let getAllDecryptedForUrlSpy: jest.SpyInstance; + const mockFormattedURI = "archive.org"; + const mockFormURI = "https://www.archive.org"; + const expectSkippedCheckingNotification = () => { + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + }; + + beforeEach(() => { + tab = createChromeTabMock(); + sender = mock({ tab }); + getEnableAddedLoginPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableAddedLoginPrompt", + ); + getEnableChangedPasswordPromptSpy = jest.spyOn( + notificationBackground as any, + "getEnableChangedPasswordPrompt", + ); + + pushChangePasswordToQueueSpy = jest.spyOn( + notificationBackground as any, + "pushChangePasswordToQueue", + ); + pushAddLoginToQueueSpy = jest.spyOn(notificationBackground as any, "pushAddLoginToQueue"); + getAllDecryptedForUrlSpy = jest.spyOn(cipherService, "getAllDecryptedForUrl"); + }); + + afterEach(() => { + getEnableAddedLoginPromptSpy.mockRestore(); + getEnableChangedPasswordPromptSpy.mockRestore(); + pushChangePasswordToQueueSpy.mockRestore(); + pushAddLoginToQueueSpy.mockRestore(); + getAllDecryptedForUrlSpy.mockRestore(); + }); + + it("skips checking if a notification should trigger if no fields were filled", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the passed url is not valid", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: "", + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "I<3VogonPoetry", username: "ADent" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the user has disabled both the new login and update password notification", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the user is logged out", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if there is no active account", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Bab3lPhs5h", + password: "I<3VogonPoetry", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + accountService.activeAccount$ = new BehaviorSubject(null); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the values for the `password` and `newPassword` fields match (no change)", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Beeblebrox4Prez", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("skips checking if a notification should trigger if the vault is locked and there is no value for the `newPassword` field", async () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "Beeblebrox4Prez", + uri: mockFormURI, + username: "ADent", + }; + + const storedCiphersForURL = [ + mock({ login: { username: "ADent", password: "I<3VogonPoetry" } }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + describe("when `username` and `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "Edro2x", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "gandalfG", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock prompt followed by an update password prompt + ); + }); + + it("and cipher update candidates match `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `newPassword` only, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `password` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `password` only, as well as `newPassword` only, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` only, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` only, as well as `password` or `newPassword` only, trigger an update cipher notification with the candidates `username`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "EdroEdro", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `newPassword` as well as any other combination of `username`, `password`, and/or `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "sting123", username: "BBaggins" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "Edro2x", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "Edro2x", username: "FBaggins" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-7", + login: { password: "ShyerH1re", username: "gandalfG" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger an update cipher notification if the update notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` as well as any OTHER combination of `username`, `password`, and/or `newPassword` (excluding `username` AND `newPassword`), trigger an update cipher notification with the candidates matching `username` AND `password`", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "UShallKnotPassword", username: "TBombadil" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "Edro2x", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "UShallKnotPassword", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-4", + login: { password: "flyUPh00lz", username: "gandalfG" }, + }), + mock({ + id: "cipher-id-5", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-6", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, `password`, nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { password: "galadriel4Eva", username: "gandalfW" }, + }), + mock({ + id: "cipher-id-2", + login: { password: "EdroEdro", username: "shadowfax" }, + }), + mock({ + id: "cipher-id-3", + login: { password: "sting123", username: "BBaggins" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "2ndBreakf4st", + password: "", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates match only `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match only `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "differentPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Pippin", password: "2ndBreakf4st" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and at least one cipher update candidate matches both `username` and `newPassword`, do not trigger an update (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "2ndBreakf4st" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Frodo", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` nor `newPassword`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "oldPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "differentPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `username` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and the user vault is locked, do not trigger a notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("and at least one cipher update candidate matches `username`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Frodo", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: "", + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "password1" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "password2" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `password` and `newPassword` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "4WzrdIzN0tLa7e", + password: "UShallKnotPassword", + username: "", + uri: mockFormURI, + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "GaldalfG", password: "4WzrdIzN0tLa7e" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "GaldalfW", password: "4WzrdIzN0tLa7e" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "Pippin", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "Merry", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password` or `newPassword`, do not trigger a notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "Frodo", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "11sies" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `password` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "UShallKnotPassword", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, do not trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expectSkippedCheckingNotification(); + }); + + it("and cipher update candidates only match `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `password`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and no cipher update candidates match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "MahPr3c10us" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "PTook", password: "f00lOfAT00k" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when `username` and `password` fields are filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "", + password: "ShyerH1re", + uri: mockFormURI, + username: "BBaggins", + }; + + it("and cipher update candidates only match `password`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `password`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FrodoB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates only match `username`, trigger an update cipher notification with those candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2"], + mockFormattedURI, + formEntryData.password, + sender.tab, + ); + }); + + it("and cipher update candidates only match `username`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BilboB", password: "UShallKnotPassword" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` and `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and cipher update candidates match `username` AND `password` and additionally `username` OR `password`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "BBaggins", password: "UShallKnotPassword" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-4", + login: { username: "TBombadil", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `username` or `password`, trigger a new cipher notification", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.password, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); + }); + + it("and no cipher update candidates match `username` or `password`, do not trigger a new cipher notification if the new cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "BilboB", password: "PutAR1ngOnIt" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableAddedLoginPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + + describe("when only `newPassword` field is filled, ", () => { + const formEntryData: ModifyLoginCipherFormData = { + newPassword: "ShyerH1re", + password: "", + uri: mockFormURI, + username: "", + }; + + it("and the user vault is locked, trigger an unlock notification", async () => { + activeAccountStatusMock$.next(AuthenticationStatus.Locked); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(getAllDecryptedForUrlSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + null, + mockFormattedURI, + formEntryData.newPassword, + tab, + true, // will yield an unlock followed by an update password notification + ); + }); + + it("and cipher update candidates only match `newPassword`, do not trigger a notification (nothing to change)", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "ShyerH1re" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "ShyerH1re" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + + it("and no cipher update candidates match `newPassword`, trigger an update cipher notification with ALL cipher update candidates", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushChangePasswordToQueueSpy).toHaveBeenCalledWith( + ["cipher-id-1", "cipher-id-2", "cipher-id-3"], + mockFormattedURI, + formEntryData.newPassword, + sender.tab, + ); + }); + + it("and no cipher update candidates match `newPassword`, do not trigger an update cipher notification if the update cipher notification setting is disabled", async () => { + const storedCiphersForURL = [ + mock({ + id: "cipher-id-1", + login: { username: "FBaggins", password: "W0nWr1ng" }, + }), + mock({ + id: "cipher-id-2", + login: { username: "PTook", password: "PutAR1ngOnIt" }, + }), + mock({ + id: "cipher-id-3", + login: { username: "SamwiseG", password: "P0t4toes" }, + }), + ]; + + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); + getAllDecryptedForUrlSpy.mockResolvedValueOnce(storedCiphersForURL); + getEnableChangedPasswordPromptSpy.mockReturnValueOnce(false); + + await notificationBackground.triggerCipherNotification(formEntryData, tab); + + expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + }); + }); + }); + describe("bgRemoveTabFromNotificationQueue message handler", () => { it("splices a notification queue item based on the passed tab", async () => { const tab = createChromeTabMock({ id: 2 }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index f8459cf8f23..33d65391c25 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -22,6 +22,7 @@ import { import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service"; import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { NeverDomains } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; @@ -79,6 +80,30 @@ import { } from "./abstractions/overlay-notifications.background"; import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background"; +const inputScenarios = { + usernamePasswordNewPassword: "usernamePasswordNewPassword", + usernameNewPassword: "usernameNewPassword", + usernamePassword: "usernamePassword", + username: "username", + passwordNewPassword: "passwordNewPassword", + newPassword: "newPassword", + password: "password", +} as const; + +type InputScenarioKey = keyof typeof inputScenarios; +type InputScenario = (typeof inputScenarios)[InputScenarioKey]; + +type CiphersByInputMatchCategory = { + allFieldMatches: CipherView["id"][]; + newPasswordOnlyMatches: CipherView["id"][]; + noFieldMatches: CipherView["id"][]; + passwordNewPasswordMatches: CipherView["id"][]; + passwordOnlyMatches: CipherView["id"][]; + usernameNewPasswordMatches: CipherView["id"][]; + usernameOnlyMatches: CipherView["id"][]; + usernamePasswordMatches: CipherView["id"][]; +}; + export default class NotificationBackground { private openUnlockPopout = openUnlockPopout; private openAddEditVaultItemPopout = openAddEditVaultItemPopout; @@ -152,6 +177,10 @@ export default class NotificationBackground { this.cleanupNotificationQueue(); } + useUndeterminedCipherScenarioTriggeringLogic$ = this.configService.getFeatureFlag$( + FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic, + ); + /** * Gets the enableChangedPasswordPrompt setting from the user notification settings service. */ @@ -292,7 +321,7 @@ export default class NotificationBackground { type: CipherType.Login, reprompt, favorite, - ...(organizationCategories.length ? { organizationCategories } : {}), + ...(organizationCategories.length > 0 ? { organizationCategories } : {}), icon: buildCipherIcon(iconsServerUrl, view, showFavicons), login: login && { username: login.username }, }; @@ -309,7 +338,7 @@ export default class NotificationBackground { activeUserId: UserId, ): Promise { const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId); - if (!tasks?.length) { + if (!(tasks?.length > 0)) { return null; } @@ -317,7 +346,7 @@ export default class NotificationBackground { modifyLoginData.uri, activeUserId, ); - if (!urlCiphers?.length) { + if (!(urlCiphers?.length > 0)) { return null; } @@ -596,6 +625,216 @@ export default class NotificationBackground { await this.checkNotificationQueue(tab); } + /** + * Receives filled form values and determines if a notification should be + * triggered, and if so, what kind and with what data. + * + * If an update scenario is identified, a change password message is added to the + * notification queue, prompting the user to update a stored login that has changed. + * + * A new cipher notification is triggered in other defined scenarios + * with the user's form input. + * + * Returns `true` or `false` to indicate if such a notification was + * triggered or not. + * + * For the purposes of this function, form field inputs should be assumed to be + * qualified accurately. + */ + async triggerCipherNotification( + data: ModifyLoginCipherFormData, + tab: chrome.tabs.Tab, + ): Promise { + const usernameFieldValue: string | null = data.username || null; + const currentPasswordFieldValue = data.password || null; + const newPasswordFieldValue = data.newPassword || null; + + // If no values were entered, exit early + if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) { + return false; + } + + // If the entered data doesn't have an associated URI, exit early + const loginDomain = Utils.getDomain(data.uri); + if (loginDomain === null) { + return false; + } + + // If no cipher add/update notifications are enabled, we can exit early + const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt(); + const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt(); + if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) { + return false; + } + + // If there is no account logged in (as opposed to only being locked), exit early + const authStatus = await this.getAuthStatus(); + if (authStatus === AuthenticationStatus.LoggedOut) { + return false; + } + + // If there is no active user, exit early + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (activeUserId === null) { + return false; + } + + const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : ""; + const currentPasswordFieldHasValue = + typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0; + const newPasswordFieldHasValue = + typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0; + const usernameFieldHasValue = + typeof usernameFieldValue === "string" && usernameFieldValue.length > 0; + + // If the current and new password inputs both have values and those values + // match, return early, since no change was made + if ( + currentPasswordFieldHasValue && + newPasswordFieldHasValue && + currentPasswordFieldValue === newPasswordFieldValue + ) { + return false; + } + + /* + * We only show the unlock notification if a new password field was filled, since + * it's very likely to blindly represent an updated cipher value whereas other + * scenarios below require the vault to be unlocked in order to determine + * if an update has been made. + */ + if (authStatus === AuthenticationStatus.Locked) { + if (!newPasswordFieldHasValue) { + return false; + } + // This needs to be the call that includes the full form data + await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true); + + return true; + } + + const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl( + data.uri, + activeUserId, + ); + + // Reducer structured to avoid subsequent array iterations + const ciphersByInputMatchCategory = ciphersForURL.reduce( + (acc, { id, login }) => { + const usernameInputMatchesCipher = + usernameFieldHasValue && login.username?.toLowerCase() === normalizedUsername; + const passwordInputMatchesCipher = + currentPasswordFieldHasValue && login.password === currentPasswordFieldValue; + const newPasswordInputMatchesCipher = + newPasswordFieldHasValue && login.password === newPasswordFieldValue; + + if ( + !newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + usernameInputMatchesCipher && + passwordInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] }; + } else if ( + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + !usernameInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] }; + } else if ( + passwordInputMatchesCipher && + newPasswordInputMatchesCipher && + !usernameInputMatchesCipher + ) { + // Note: this case should be unreachable due to the early exit comparing + // the password input values against each other, but leaving this bit here + // as a defense against future changes to the pre-match checks. + return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + !passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] }; + } else if ( + usernameInputMatchesCipher && + passwordInputMatchesCipher && + !newPasswordInputMatchesCipher + ) { + return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] }; + } else if ( + usernameInputMatchesCipher && + newPasswordInputMatchesCipher && + !passwordInputMatchesCipher + ) { + return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] }; + } + + return acc; + }, + { + allFieldMatches: [], + newPasswordOnlyMatches: [], + noFieldMatches: [], + passwordNewPasswordMatches: [], + passwordOnlyMatches: [], + usernameNewPasswordMatches: [], + usernameOnlyMatches: [], + usernamePasswordMatches: [], + }, + ); + + // Handle different field fill combinations and determine the input scenario + const inputScenariosByKey = { + upn: inputScenarios.usernamePasswordNewPassword, + un: inputScenarios.usernameNewPassword, + up: inputScenarios.usernamePassword, + u: inputScenarios.username, + pn: inputScenarios.passwordNewPassword, + n: inputScenarios.newPassword, + p: inputScenarios.password, + } as const; + + type InputScenarioKeys = keyof typeof inputScenariosByKey; + + const key = ((usernameFieldHasValue ? "u" : "") + + (currentPasswordFieldHasValue ? "p" : "") + + (newPasswordFieldHasValue ? "n" : "")) as InputScenarioKeys; + + const inputScenario = key in inputScenariosByKey ? inputScenariosByKey[key] : null; + + if (inputScenario) { + return await this.handleInputMatchScenario({ + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + inputScenario, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }); + } + + return false; + } + /** * Adds a change password message to the notification queue, prompting the user * to update the password for a login that has changed. @@ -668,13 +907,14 @@ export default class NotificationBackground { if ( ciphers.length > 0 && - currentPasswordFieldValue?.length && + (currentPasswordFieldValue?.length || 0) > 0 && // Only use current password for change if no new password present. !newPasswordFieldValue ) { const currentPasswordMatchesAnExistingValue = ciphers.some( (cipher) => - cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue, + (cipher.login?.password?.length || 0) > 0 && + cipher.login.password === currentPasswordFieldValue, ); // The password entered matched a stored cipher value with @@ -710,6 +950,212 @@ export default class NotificationBackground { return false; } + private async handleInputMatchScenario({ + inputScenario, + ciphersByInputMatchCategory, + ciphersForURL, + loginDomain, + tab, + data, + changePasswordNotificationIsEnabled, + newLoginNotificationIsEnabled, + }: { + ciphersByInputMatchCategory: CiphersByInputMatchCategory; + ciphersForURL: CipherView[]; + loginDomain: string; + tab: chrome.tabs.Tab; + data: ModifyLoginCipherFormData; + inputScenario: InputScenario; + changePasswordNotificationIsEnabled: boolean; + newLoginNotificationIsEnabled: boolean; + }): Promise { + const { + newPasswordOnlyMatches, + noFieldMatches, + passwordOnlyMatches, + usernameNewPasswordMatches, + usernameOnlyMatches, + usernamePasswordMatches, + } = ciphersByInputMatchCategory; + // IMPORTANT! The order of statements matters here; later evaluations + // depend on the assumptions of the early exits in preceding logic + + // If no ciphers match any filled input values + // (Note, this block may uniquely exit early since this match scenario + // involves all ciphers, making it mutually exclusive from any other scenario) + if (noFieldMatches.length === ciphersForURL.length) { + // trigger a new cipher notification in these input scenarios + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + inputScenarios.username, + ] as InputScenario[] + ).includes(inputScenario) && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + + // Trigger an update cipher notification with all URI ciphers + // in these input scenarios + if ( + ([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes( + inputScenario, + ) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + ciphersForURL.map((c) => c.id), + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + return false; + } + + // If ciphers match entered username and new password values + if (usernameNewPasswordMatches.length > 0) { + // Early exit in these scenarios as they represent "no change" + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + } + + // If ciphers match entered username and password values + if (usernamePasswordMatches.length > 0) { + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernamePasswordMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + if (inputScenario === inputScenarios.usernamePassword) { + return false; + } + } + + // If ciphers match entered username value (only) + if (usernameOnlyMatches.length > 0) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernameNewPassword, + inputScenarios.usernamePassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + usernameOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.username) { + return false; + } + } + + // If ciphers match entered new password value (only) + if (newPasswordOnlyMatches.length > 0) { + // Early exit in these scenarios + if ( + ( + [ + inputScenarios.usernameNewPassword, // unclear user expectation + inputScenarios.password, // likely nothing to change + inputScenarios.newPassword, // nothing to change + ] as InputScenario[] + ).includes(inputScenario) + ) { + return false; + } + + // and username, password, and new password values were entered + if ( + inputScenario === inputScenarios.usernamePasswordNewPassword && + newLoginNotificationIsEnabled + ) { + await this.pushAddLoginToQueue( + loginDomain, + { username: data.username, url: data.uri, password: data.newPassword || data.password }, + tab, + ); + + return true; + } + } + + // If ciphers match entered password value (only) + if (passwordOnlyMatches.length > 0) { + if ( + ( + [ + inputScenarios.usernamePasswordNewPassword, + inputScenarios.usernamePassword, + inputScenarios.passwordNewPassword, + ] as InputScenario[] + ).includes(inputScenario) && + changePasswordNotificationIsEnabled + ) { + await this.pushChangePasswordToQueue( + passwordOnlyMatches, + loginDomain, + // @TODO handle empty strings / incomplete data structure + data.newPassword || data.password, + tab, + ); + + return true; + } + + // Early exit in this scenario as it represents "no change" + if (inputScenario === inputScenarios.password) { + return false; + } + } + + return false; + } + /** * Sends the page details to the notification bar. Will query all * forms with a password field and pass them to the notification bar. @@ -730,6 +1176,7 @@ export default class NotificationBackground { }); } + // @TODO this needs the whole input record, and not just newPassword private async pushChangePasswordToQueue( cipherIds: CipherView["id"][], loginDomain: string, diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts index c596a1ba774..28e03b64621 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.spec.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; @@ -32,6 +33,7 @@ describe("OverlayNotificationsBackground", () => { jest.useFakeTimers(); logService = mock(); notificationBackground = mock(); + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false); getEnableChangedPasswordPromptSpy = jest .spyOn(notificationBackground, "getEnableChangedPasswordPrompt") .mockResolvedValue(true); @@ -323,6 +325,7 @@ describe("OverlayNotificationsBackground", () => { const pageDetails = mock({ fields: [mock()] }); let notificationChangedPasswordSpy: jest.SpyInstance; let notificationAddLoginSpy: jest.SpyInstance; + let cipherNotificationSpy: jest.SpyInstance; beforeEach(async () => { sender = mock({ @@ -334,6 +337,7 @@ describe("OverlayNotificationsBackground", () => { "triggerChangedPasswordNotification", ); notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification"); + cipherNotificationSpy = jest.spyOn(notificationBackground, "triggerCipherNotification"); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, @@ -456,6 +460,7 @@ describe("OverlayNotificationsBackground", () => { const pageDetails = mock({ fields: [mock()] }); beforeEach(async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false); sendMockExtensionMessage( { command: "collectPageDetailsResponse", details: pageDetails }, sender, @@ -519,6 +524,44 @@ describe("OverlayNotificationsBackground", () => { expect(notificationAddLoginSpy).toHaveBeenCalled(); }); + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "loading", + url: sender.url, + }), + ); + }); + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + await flushPromises(); + + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + triggerWebNavigationOnCompletedEvent( + mock({ + tabId: sender.tab.id, + url: sender.url, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); + it("initializes the notification immediately when the tab's navigation is complete", async () => { sendMockExtensionMessage( { @@ -552,6 +595,40 @@ describe("OverlayNotificationsBackground", () => { expect(notificationAddLoginSpy).toHaveBeenCalled(); }); + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, initializes the notification immediately when the tab's navigation is complete", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "username", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnCompletedEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + requestId, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); + it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { sender.tab = mock({ id: 4 }); sendMockExtensionMessage( @@ -601,6 +678,57 @@ describe("OverlayNotificationsBackground", () => { expect(notificationChangedPasswordSpy).toHaveBeenCalled(); }); + + it("with `useUndeterminedCipherScenarioTriggeringLogic` on, triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => { + notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true); + sender.tab = mock({ id: 4 }); + sendMockExtensionMessage( + { command: "collectPageDetailsResponse", details: pageDetails }, + sender, + ); + await flushPromises(); + sendMockExtensionMessage( + { + command: "formFieldSubmitted", + uri: "example.com", + username: "", + password: "password", + newPassword: "newPassword", + }, + sender, + ); + await flushPromises(); + chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => { + callback( + mock({ + status: "complete", + url: sender.url, + }), + ); + }); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: sender.url, + tabId: sender.tab.id, + method: "POST", + requestId, + }), + ); + await flushPromises(); + + triggerWebRequestOnBeforeRequestEvent( + mock({ + url: "https://example.com/redirect", + tabId: sender.tab.id, + method: "GET", + requestId, + }), + ); + await flushPromises(); + + expect(cipherNotificationSpy).toHaveBeenCalled(); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index 4f55e68fb41..dea6dc5c44c 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -1,10 +1,9 @@ -import { Subject, switchMap, timer } from "rxjs"; +import { firstValueFrom, Subject, switchMap, timer } from "rxjs"; import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar"; import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; import { @@ -14,6 +13,8 @@ import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface, OverlayNotificationsExtensionMessage, OverlayNotificationsExtensionMessageHandlers, + NotificationScenarios, + NotificationScenario, WebsiteOriginsWithFields, } from "./abstractions/overlay-notifications.background"; import NotificationBackground from "./notification.background"; @@ -32,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg collectPageDetailsResponse: ({ message, sender }) => this.handleCollectPageDetailsResponse(message, sender), }; - constructor( private logService: LogService, private notificationBackground: NotificationBackground, @@ -281,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptAddNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Add, + NotificationScenarios.Add, ); if (shouldAttemptAddNotification) { @@ -290,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg const shouldAttemptChangeNotification = this.shouldAttemptNotification( modifyLoginData, - NotificationTypes.Change, + NotificationScenarios.Change, ); if (shouldAttemptChangeNotification) { @@ -445,29 +445,45 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg requestId: chrome.webRequest.WebRequestDetails["requestId"], modifyLoginData: ModifyLoginCipherFormData, tab: chrome.tabs.Tab, - config: { skippable: NotificationType[] } = { skippable: [] }, + config: { skippable: NotificationScenario[] } = { skippable: [] }, ) => { - const notificationCandidates = [ - { - type: NotificationTypes.Change, - trigger: this.notificationBackground.triggerChangedPasswordNotification, - }, - { - type: NotificationTypes.Add, - trigger: this.notificationBackground.triggerAddLoginNotification, - }, - { - type: NotificationTypes.AtRiskPassword, - trigger: this.notificationBackground.triggerAtRiskPasswordNotification, - }, - ].filter( + const useUndeterminedCipherScenarioTriggeringLogic = await firstValueFrom( + this.notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$, + ); + + const notificationCandidates = useUndeterminedCipherScenarioTriggeringLogic + ? [ + { + type: NotificationScenarios.Cipher, + trigger: this.notificationBackground.triggerCipherNotification, + }, + { + type: NotificationScenarios.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ] + : [ + { + type: NotificationScenarios.Change, + trigger: this.notificationBackground.triggerChangedPasswordNotification, + }, + { + type: NotificationScenarios.Add, + trigger: this.notificationBackground.triggerAddLoginNotification, + }, + { + type: NotificationScenarios.AtRiskPassword, + trigger: this.notificationBackground.triggerAtRiskPasswordNotification, + }, + ]; + const filteredNotificationCandidates = notificationCandidates.filter( (candidate) => this.shouldAttemptNotification(modifyLoginData, candidate.type) || config.skippable.includes(candidate.type), ); const results: string[] = []; - for (const { trigger, type } of notificationCandidates) { + for (const { trigger, type } of filteredNotificationCandidates) { const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab); if (success) { results.push(`Success: ${type}`); @@ -489,8 +505,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private shouldAttemptNotification = ( modifyLoginData: ModifyLoginCipherFormData, - notificationType: NotificationType, + notificationType: NotificationScenario, ): boolean => { + if (notificationType === NotificationScenarios.Cipher) { + // The logic after this block pre-qualifies some cipher add/update scenarios + // prematurely (where matching against vault contents is required) and should be + // skipped for this case (these same checks are performed early in the + // notification triggering logic). + return true; + } + // Intentionally not stripping whitespace characters here as they // represent user entry. const usernameFieldHasValue = !!(modifyLoginData?.username || "").length; @@ -504,15 +528,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg // `Add` case included because all forms with cached usernames (from previous // visits) will appear to be "password only" and otherwise trigger the new login // save notification. - case NotificationTypes.Add: + case NotificationScenarios.Add: // Can be values for nonstored login or account creation return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue); - case NotificationTypes.Change: + case NotificationScenarios.Change: // Can be login with nonstored login changes or account password update return canBeUserLogin || canBePasswordUpdate; - case NotificationTypes.AtRiskPassword: + case NotificationScenarios.AtRiskPassword: return !newPasswordFieldHasValue; - case NotificationTypes.Unlock: + case NotificationScenarios.Unlock: // Unlock notifications are handled separately and do not require form data return false; default: diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index b23c3c17abb..923db8d4b5c 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -8,9 +8,13 @@ import { } from "../../../autofill/content/components/common-types"; const NotificationTypes = { + /** represents scenarios handling saving new ciphers after form submit */ Add: "add", + /** represents scenarios handling saving updated ciphers after form submit */ Change: "change", + /** represents scenarios where user has interacted with an unlock action prompt or action otherwise requiring unlock as a prerequisite */ Unlock: "unlock", + /** represents scenarios where the user has security tasks after updating ciphers */ AtRiskPassword: "at-risk-password", } as const; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index dc960baae1d..9941e7671f4 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -22,6 +22,7 @@ export enum FeatureFlag { SafariAccountSwitching = "pm-5594-safari-account-switching", /* Autofill */ + UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic", MacOsNativeCredentialSync = "macos-native-credential-sync", WindowsDesktopAutotype = "windows-desktop-autotype", WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga", @@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.MembersComponentRefactor]: FALSE, /* Autofill */ + [FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.WindowsDesktopAutotype]: FALSE, [FeatureFlag.WindowsDesktopAutotypeGA]: FALSE, From 8ceb28f2b93b6eeaa8e8a0db6a11c05d61d61bd8 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:49:11 -0500 Subject: [PATCH 114/130] default weakness sort to descending order (PM-31164) (#18719) --- .../app/dirt/reports/pages/weak-passwords-report.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d96d083ffe0..5f047316a29 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 @@ -54,7 +54,7 @@ {{ "owner" | i18n }} } - + {{ "weakness" | i18n }} From a048827c0ebf90dcdb9e513911c9afb08c9e96af Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:21:18 -0800 Subject: [PATCH 115/130] don't allow unarchiving in AC (#18637) --- .../vault/components/vault-items/vault-cipher-row.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index f795f9533eb..6400c0ca9a8 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -157,7 +157,7 @@ export class VaultCipherRowComponent implements OnInit // If item is archived always show unarchive button, even if user is not premium protected get showUnArchiveButton() { - if (!this.archiveEnabled()) { + if (!this.archiveEnabled() || this.viewingOrgVault) { return false; } From 50b8dde03157461e273604430cf40173402c01b9 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:23:13 -0800 Subject: [PATCH 116/130] [PM-31240[ - [Defect] Toast message archiving an item in Edit/View item modal is in plural form (#18578) * fix archive toast * fix bulk share in vault * Revert "fix bulk share in vault" This reverts commit dfb309c8c5445d9a45f6f089e6f304cc0ad21d14. --- .../components/vault-item-dialog/vault-item-dialog.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 90452ba573a..ef861b7cab3 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -593,7 +593,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemsWereSentToArchive"), + message: this.i18nService.t("itemWasSentToArchive"), }); } catch { this.toastService.showToast({ From 2fb63e8f41d5d1f8011f2974faa1d274b69b1295 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Tue, 3 Feb 2026 05:36:43 +0800 Subject: [PATCH 117/130] [PM-30266] Improve Buttercup CSV import mapping (#18135) - Handle url field case-insensitively (URL, url, Url) - Map note field to cipher notes - Add !type to official props to exclude from custom fields - Only add non-empty custom fields - Add comprehensive unit tests Fixes #17119 Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com> --- .../importers/buttercup-csv-importer.spec.ts | 87 +++++++++++++++++++ .../src/importers/buttercup-csv-importer.ts | 33 +++++-- .../spec-data/buttercup-csv/testdata.csv.ts | 16 ++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 libs/importer/src/importers/buttercup-csv-importer.spec.ts create mode 100644 libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts diff --git a/libs/importer/src/importers/buttercup-csv-importer.spec.ts b/libs/importer/src/importers/buttercup-csv-importer.spec.ts new file mode 100644 index 00000000000..51c9d4cb2d8 --- /dev/null +++ b/libs/importer/src/importers/buttercup-csv-importer.spec.ts @@ -0,0 +1,87 @@ +import { ButtercupCsvImporter } from "./buttercup-csv-importer"; +import { + buttercupCsvTestData, + buttercupCsvWithCustomFieldsTestData, + buttercupCsvWithNoteTestData, + buttercupCsvWithSubfoldersTestData, + buttercupCsvWithUrlFieldTestData, +} from "./spec-data/buttercup-csv/testdata.csv"; + +describe("Buttercup CSV Importer", () => { + let importer: ButtercupCsvImporter; + + beforeEach(() => { + importer = new ButtercupCsvImporter(); + }); + + describe("given basic login data", () => { + it("should parse login data when provided valid CSV", async () => { + const result = await importer.parse(buttercupCsvTestData); + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(2); + + const cipher = result.ciphers[0]; + expect(cipher.name).toEqual("Test Entry"); + expect(cipher.login.username).toEqual("testuser"); + expect(cipher.login.password).toEqual("testpass123"); + expect(cipher.login.uris.length).toEqual(1); + expect(cipher.login.uris[0].uri).toEqual("https://example.com"); + }); + + it("should assign entries to folders based on group_name", async () => { + const result = await importer.parse(buttercupCsvTestData); + expect(result.success).toBe(true); + expect(result.folders.length).toBe(1); + expect(result.folders[0].name).toEqual("General"); + expect(result.folderRelationships.length).toBe(2); + }); + }); + + describe("given URL field variations", () => { + it("should handle lowercase url field", async () => { + const result = await importer.parse(buttercupCsvWithUrlFieldTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.login.uris.length).toEqual(1); + expect(cipher.login.uris[0].uri).toEqual("https://lowercase-url.com"); + }); + }); + + describe("given note field", () => { + it("should map note field to notes", async () => { + const result = await importer.parse(buttercupCsvWithNoteTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.notes).toEqual("This is a note"); + }); + }); + + describe("given custom fields", () => { + it("should import custom fields and exclude official props", async () => { + const result = await importer.parse(buttercupCsvWithCustomFieldsTestData); + expect(result.success).toBe(true); + + const cipher = result.ciphers[0]; + expect(cipher.fields.length).toBe(2); + expect(cipher.fields[0].name).toEqual("custom_field"); + expect(cipher.fields[0].value).toEqual("custom value"); + expect(cipher.fields[1].name).toEqual("another_field"); + expect(cipher.fields[1].value).toEqual("another value"); + }); + }); + + describe("given subfolders", () => { + it("should create nested folder structure", async () => { + const result = await importer.parse(buttercupCsvWithSubfoldersTestData); + expect(result.success).toBe(true); + + const folderNames = result.folders.map((f) => f.name); + expect(folderNames).toContain("Work/Projects"); + expect(folderNames).toContain("Work"); + expect(folderNames).toContain("Personal/Finance"); + expect(folderNames).toContain("Personal"); + }); + }); +}); diff --git a/libs/importer/src/importers/buttercup-csv-importer.ts b/libs/importer/src/importers/buttercup-csv-importer.ts index ac3a4cd2512..07fe53bc625 100644 --- a/libs/importer/src/importers/buttercup-csv-importer.ts +++ b/libs/importer/src/importers/buttercup-csv-importer.ts @@ -3,7 +3,18 @@ import { ImportResult } from "../models/import-result"; import { BaseImporter } from "./base-importer"; import { Importer } from "./importer"; -const OfficialProps = ["!group_id", "!group_name", "title", "username", "password", "URL", "id"]; +const OfficialProps = [ + "!group_id", + "!group_name", + "!type", + "title", + "username", + "password", + "URL", + "url", + "note", + "id", +]; export class ButtercupCsvImporter extends BaseImporter implements Importer { parse(data: string): Promise { @@ -21,16 +32,24 @@ export class ButtercupCsvImporter extends BaseImporter implements Importer { cipher.name = this.getValueOrDefault(value.title, "--"); cipher.login.username = this.getValueOrDefault(value.username); cipher.login.password = this.getValueOrDefault(value.password); - cipher.login.uris = this.makeUriArray(value.URL); - let processingCustomFields = false; + // Handle URL field (case-insensitive) + const urlValue = value.URL || value.url || value.Url; + cipher.login.uris = this.makeUriArray(urlValue); + + // Handle note field (case-insensitive) + const noteValue = value.note || value.Note || value.notes || value.Notes; + if (noteValue) { + cipher.notes = noteValue; + } + + // Process custom fields, excluding official props (case-insensitive) for (const prop in value) { // eslint-disable-next-line if (value.hasOwnProperty(prop)) { - if (!processingCustomFields && OfficialProps.indexOf(prop) === -1) { - processingCustomFields = true; - } - if (processingCustomFields) { + const lowerProp = prop.toLowerCase(); + const isOfficialProp = OfficialProps.some((p) => p.toLowerCase() === lowerProp); + if (!isOfficialProp && value[prop]) { this.processKvp(cipher, prop, value[prop]); } } diff --git a/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts b/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts new file mode 100644 index 00000000000..5e2f7a8d38c --- /dev/null +++ b/libs/importer/src/importers/spec-data/buttercup-csv/testdata.csv.ts @@ -0,0 +1,16 @@ +export const buttercupCsvTestData = `!group_id,!group_name,title,username,password,URL,id +1,General,Test Entry,testuser,testpass123,https://example.com,entry1 +1,General,Another Entry,anotheruser,anotherpass,https://another.com,entry2`; + +export const buttercupCsvWithUrlFieldTestData = `!group_id,!group_name,title,username,password,url,id +1,General,Entry With Lowercase URL,user1,pass1,https://lowercase-url.com,entry1`; + +export const buttercupCsvWithNoteTestData = `!group_id,!group_name,title,username,password,URL,note,id +1,General,Entry With Note,user1,pass1,https://example.com,This is a note,entry1`; + +export const buttercupCsvWithCustomFieldsTestData = `!group_id,!group_name,title,username,password,URL,custom_field,another_field,id +1,General,Entry With Custom Fields,user1,pass1,https://example.com,custom value,another value,entry1`; + +export const buttercupCsvWithSubfoldersTestData = `!group_id,!group_name,title,username,password,URL,id +1,Work/Projects,Project Entry,projectuser,projectpass,https://project.com,entry1 +2,Personal/Finance,Finance Entry,financeuser,financepass,https://finance.com,entry2`; From 201d36201f72a7a9e7d2524aa6a9fd505ccb7f49 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:38:24 -0700 Subject: [PATCH 118/130] [PM-30247] Previously archived items are not archived after import (#18546) --- .../bitwarden/bitwarden-csv-importer.spec.ts | 29 +++++++++++++++++++ .../bitwarden/bitwarden-csv-importer.ts | 9 ++++++ .../src/services/base-vault-export.service.ts | 1 + .../src/types/bitwarden-csv-export-type.ts | 1 + 4 files changed, 40 insertions(+) diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts index e66779f0372..8f1a281050f 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.spec.ts @@ -91,4 +91,33 @@ describe("BitwardenCsvImporter", () => { expect(result.collections[0].name).toBe("collection1/collection2"); expect(result.collections[1].name).toBe("collection1"); }); + + it("should parse archived items correctly", async () => { + const archivedDate = "2025-01-15T10:30:00.000Z"; + const data = + `name,type,archivedDate,login_uri,login_username,login_password` + + `\nArchived Login,login,${archivedDate},https://example.com,user,pass`; + + importer.organizationId = null; + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + + const cipher = result.ciphers[0]; + expect(cipher.name).toBe("Archived Login"); + expect(cipher.archivedDate).toBeDefined(); + expect(cipher.archivedDate.toISOString()).toBe(archivedDate); + }); + + it("should handle missing archivedDate gracefully", async () => { + const data = `name,type,login_uri` + `\nTest Login,login,https://example.com`; + + importer.organizationId = null; + const result = await importer.parse(data); + + expect(result.success).toBe(true); + expect(result.ciphers.length).toBe(1); + expect(result.ciphers[0].archivedDate).toBeUndefined(); + }); }); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts index b900e9e8d7a..cca1b80e3bd 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-csv-importer.ts @@ -51,6 +51,15 @@ export class BitwardenCsvImporter extends BaseImporter implements Importer { cipher.reprompt = CipherRepromptType.None; } + if (!this.isNullOrWhitespace(value.archivedDate)) { + try { + cipher.archivedDate = new Date(value.archivedDate); + } catch (e) { + // eslint-disable-next-line + console.error("Unable to parse archivedDate value", e); + } + } + if (!this.isNullOrWhitespace(value.fields)) { const fields = this.splitNewLine(value.fields); for (let i = 0; i < fields.length; i++) { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts index 620f465789c..7adf7b4138f 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts @@ -59,6 +59,7 @@ export class BaseVaultExportService { cipher.notes = c.notes; cipher.fields = null; cipher.reprompt = c.reprompt; + cipher.archivedDate = c.archivedDate ? c.archivedDate.toISOString() : null; // Login props cipher.login_uri = null; cipher.login_username = null; diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts index 30c6bb89bc1..efe15a844fc 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-csv-export-type.ts @@ -12,6 +12,7 @@ export type BitwardenCsvExportType = { login_password: string; login_totp: string; favorite: number | null; + archivedDate: string | null; }; export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & { From 9db65f889586d475ff76d45fa67a909bfb07e9bb Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:13:56 -0500 Subject: [PATCH 119/130] [BRE-1531] Adding ability to build web with custom SDK branch (#18677) --- .github/workflows/build-web.yml | 18 ++++++++++++++++++ apps/web/Dockerfile | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 7b92de0f22a..71a2c62ec1a 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -63,6 +63,11 @@ jobs: node_version: ${{ steps.retrieve-node-version.outputs.node_version }} has_secrets: ${{ steps.check-secrets.outputs.has_secrets }} steps: + - name: Log inputs to job summary + uses: bitwarden/ios/.github/actions/log-inputs@main + with: + inputs: "${{ toJson(inputs) }}" + - name: Check out repo uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: @@ -181,6 +186,19 @@ jobs: ref: ${{ steps.set-server-ref.outputs.server_ref }} persist-credentials: false + - name: Download SDK Artifacts + if: ${{ inputs.sdk_branch != '' }} + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + branch: ${{ inputs.sdk_branch }} + artifacts: sdk-internal + repo: bitwarden/sdk-internal + path: sdk-internal + if_no_artifact_found: fail + - name: Check Branch to Publish env: PUBLISH_BRANCHES: "main,rc,hotfix-rc-web" diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6d27e12537a..27036e16240 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -15,6 +15,12 @@ RUN if [ "${LICENSE_TYPE}" != "commercial" ] ; then \ rm -rf node_modules/@bitwarden/commercial-sdk-internal ; \ fi +# Override SDK if custom artifacts are present +RUN if [ -d "sdk-internal" ]; then \ + echo "Overriding SDK with custom artifacts from sdk-internal" ; \ + npm link ./sdk-internal ; \ + fi + WORKDIR /source/apps/web ARG NPM_COMMAND=dist:bit:selfhost RUN npm run ${NPM_COMMAND} From 971f264c3921092d41539d9d8f27062c3427cb68 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Mon, 2 Feb 2026 18:43:46 -0500 Subject: [PATCH 120/130] [PM-31387] Desktop Footer update archive/trash btn values (#18640) * update footer component when action changes for desktop --- .../desktop/src/vault/app/vault/item-footer.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts index d601f46e430..8164a1f4a67 100644 --- a/apps/desktop/src/vault/app/vault/item-footer.component.ts +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -97,7 +97,7 @@ export class ItemFooterComponent implements OnInit, OnChanges { } async ngOnChanges(changes: SimpleChanges) { - if (changes.cipher) { + if (changes.cipher || changes.action) { await this.checkArchiveState(); } } @@ -255,12 +255,15 @@ export class ItemFooterComponent implements OnInit, OnChanges { this.userCanArchive = userCanArchive; this.showArchiveButton = - cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived; + cipherCanBeArchived && + userCanArchive && + (this.action === "view" || this.action === "edit") && + !this.cipher.isArchived; // A user should always be able to unarchive an archived item this.showUnarchiveButton = hasArchiveFlagEnabled && - this.action === "view" && + (this.action === "view" || this.action === "edit") && this.cipher.isArchived && !this.cipher.isDeleted; } From 4141b864dac9299ff4e55f92701df21db014bb54 Mon Sep 17 00:00:00 2001 From: Jackson Engstrom Date: Mon, 2 Feb 2026 16:18:30 -0800 Subject: [PATCH 121/130] [PM-24187] Improve labeling of owner filter in vault table --- .../organization-name-badge.component.html | 2 +- apps/web/src/locales/en/messages.json | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html index 4fd9539f049..5c1dc5c7f3a 100644 --- a/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html +++ b/apps/web/src/app/vault/individual-vault/organization-badge/organization-name-badge.component.html @@ -4,7 +4,7 @@ [disabled]="disabled" [style.color]="textColor" [style.background-color]="color" - appA11yTitle="{{ organizationName }}" + appA11yTitle="{{ 'ownerBadgeA11yDescription' | i18n: name }}" routerLink [queryParams]="{ organizationId: organizationIdLink }" queryParamsHandling="merge" diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d3b975e5834..1304d291235 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12742,6 +12742,15 @@ "whenYouRemoveStorage": { "message": "When you remove storage, you will receive a prorated account credit that will automatically go toward your next bill." }, + "ownerBadgeA11yDescription":{ + "message": "Owner, $OWNER$, show all items owned by $OWNER$", + "placeholders":{ + "owner": { + "content": "$1", + "example": "My Org Name" + } + } + }, "youHavePremium": { "message": "You have Premium" }, From c595767688cd2362bff89acfc40dad5219cfdbb8 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:08:44 +0100 Subject: [PATCH 122/130] [PM-29239] Create proxy cookie redirect connector (#18476) * Create a subfolder for platform-owned connectors and ensure it's included in the web builds * Add platform as codeowner of apps/web/src/connectors/platform * Create proxy-cookie-redirect connector * Create section within CODEOWNERS for Web connectors * Swap order of codeowners * Use kebap-style route * Update url to redirect to * Add override to test locally --------- Co-authored-by: Daniel James Smith --- .github/CODEOWNERS | 6 ++-- .../platform/proxy-cookie-redirect.html | 29 +++++++++++++++++++ .../platform/proxy-cookie-redirect.ts | 17 +++++++++++ apps/web/tsconfig.build.json | 2 +- apps/web/tsconfig.json | 7 ++++- apps/web/webpack.base.js | 9 ++++++ bitwarden_license/bit-web/tsconfig.build.json | 2 +- bitwarden_license/bit-web/tsconfig.json | 1 + 8 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/connectors/platform/proxy-cookie-redirect.html create mode 100644 apps/web/src/connectors/platform/proxy-cookie-redirect.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index baec07ca28d..8bb15d37fdf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,6 +15,10 @@ apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-managemen apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.toml +# Web connectors +apps/web/src/connectors @bitwarden/team-auth-dev +apps/web/src/connectors/platform @bitwarden/team-platform-dev + ## Auth team files ## apps/browser/src/auth @bitwarden/team-auth-dev apps/cli/src/auth @bitwarden/team-auth-dev @@ -22,8 +26,6 @@ apps/desktop/src/auth @bitwarden/team-auth-dev apps/web/src/app/auth @bitwarden/team-auth-dev libs/auth @bitwarden/team-auth-dev libs/user-core @bitwarden/team-auth-dev -# web connectors used for auth -apps/web/src/connectors @bitwarden/team-auth-dev bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev libs/angular/src/auth @bitwarden/team-auth-dev libs/common/src/auth @bitwarden/team-auth-dev diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.html b/apps/web/src/connectors/platform/proxy-cookie-redirect.html new file mode 100644 index 00000000000..1daa6d2e412 --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.html @@ -0,0 +1,29 @@ + + + + + + + + Bitwarden Web vault + + + + + + + + + +
    + Bitwarden +
    + +
    +
    + + diff --git a/apps/web/src/connectors/platform/proxy-cookie-redirect.ts b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts new file mode 100644 index 00000000000..79c5092caab --- /dev/null +++ b/apps/web/src/connectors/platform/proxy-cookie-redirect.ts @@ -0,0 +1,17 @@ +/** + * ONLY FOR SELF-HOSTED SETUPS + * Redirects the user to the SSO cookie vendor endpoint when the window finishes loading. + * + * This script listens for the window's load event and automatically redirects the browser + * to the `api/sso-cookie-vendor` path on the current origin. This is used as part + * of an authentication flow where cookies need to be set or validated through a vendor endpoint. + */ +window.addEventListener("DOMContentLoaded", () => { + const origin = window.location.origin; + let apiURL = `${window.location.origin}/api/sso-cookie-vendor`; + // Override for local testing + if (origin.startsWith("https://localhost")) { + apiURL = "http://localhost:4000/sso-cookie-vendor"; + } + window.location.href = apiURL; +}); diff --git a/apps/web/tsconfig.build.json b/apps/web/tsconfig.build.json index 273cddb21d2..c1e7a88f4a8 100644 --- a/apps/web/tsconfig.build.json +++ b/apps/web/tsconfig.build.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts"] + "include": ["src/connectors/*.ts", "src/connectors/platform/*.ts"] } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fd655b0a56b..6bfa9c8703b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -4,5 +4,10 @@ "strictTemplates": true }, "files": ["src/polyfills.ts", "src/main.ts", "src/theme.ts"], - "include": ["src/connectors/*.ts", "src/**/*.stories.ts", "src/**/*.spec.ts"] + "include": [ + "src/connectors/*.ts", + "src/connectors/platform/*.ts", + "src/**/*.stories.ts", + "src/**/*.spec.ts" + ] } diff --git a/apps/web/webpack.base.js b/apps/web/webpack.base.js index 016d2b0fe61..2ef9abe09a6 100644 --- a/apps/web/webpack.base.js +++ b/apps/web/webpack.base.js @@ -166,6 +166,11 @@ module.exports.buildConfig = function buildConfig(params) { filename: "duo-redirect-connector.html", chunks: ["connectors/duo-redirect", "styles"], }), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, "src/connectors/platform/proxy-cookie-redirect.html"), + filename: "proxy-cookie-redirect-connector.html", + chunks: ["connectors/platform/proxy-cookie-redirect", "styles"], + }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, "src/404.html"), filename: "404.html", @@ -403,6 +408,10 @@ module.exports.buildConfig = function buildConfig(params) { "connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"), "connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"), "connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"), + "connectors/platform/proxy-cookie-redirect": path.resolve( + __dirname, + "src/connectors/platform/proxy-cookie-redirect.ts", + ), styles: [ path.resolve(__dirname, "src/scss/styles.scss"), path.resolve(__dirname, "src/scss/tailwind.css"), diff --git a/bitwarden_license/bit-web/tsconfig.build.json b/bitwarden_license/bit-web/tsconfig.build.json index 58acbf09392..cc55f69bc4f 100644 --- a/bitwarden_license/bit-web/tsconfig.build.json +++ b/bitwarden_license/bit-web/tsconfig.build.json @@ -9,5 +9,5 @@ "../../bitwarden_license/bit-web/src/main.ts" ], - "include": ["../../apps/web/src/connectors/*.ts"] + "include": ["../../apps/web/src/connectors/*.ts", "../../apps/web/src/connectors/platform/*.ts"] } diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 8c19f771a26..8dcd128ae6b 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -11,6 +11,7 @@ ], "include": [ "../../apps/web/src/connectors/*.ts", + "../../apps/web/src/connectors/platform/*.ts", "../../apps/web/src/**/*.stories.ts", "../../apps/web/src/**/*.spec.ts", "src/**/*.stories.ts", From 3333e5696dfbcffcd3115d57fe8e5f40ec0c912d Mon Sep 17 00:00:00 2001 From: Jared Date: Tue, 3 Feb 2026 10:19:30 -0500 Subject: [PATCH 123/130] Update collection dialog to conditionally display "view" or "edit" title based on dialog state; add "viewCollection" translation to messages.json (#18724) --- .../collection-dialog/collection-dialog.component.html | 2 +- apps/web/src/locales/en/messages.json | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index 431d7711331..a2c510b78df 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -2,7 +2,7 @@ - {{ "editCollection" | i18n }} + {{ (dialogReadonly ? "viewCollection" : "editCollection") | i18n }} {{ collection.name }} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1304d291235..fe93d419035 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3805,6 +3805,9 @@ "editCollection": { "message": "Edit collection" }, + "viewCollection": { + "message": "View collection" + }, "collectionInfo": { "message": "Collection info" }, From 8fb84a46703ede9949b32d6782c57f9500af5154 Mon Sep 17 00:00:00 2001 From: tbmc Date: Tue, 3 Feb 2026 17:01:10 +0100 Subject: [PATCH 124/130] Fix layout of download Bitwarden link in settings popup (#18309) Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com> --- .../browser/src/tools/popup/settings/settings-v2.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index c6f1c9dbc3b..19f2445b61d 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -110,7 +110,7 @@ -
    +

    {{ "downloadBitwardenOnAllDevices" | i18n }}

    Date: Tue, 3 Feb 2026 11:05:02 -0500 Subject: [PATCH 125/130] when only password and new password fields have values and do not match any vault ciphers, trigger a new cipher notification (#18729) --- .../background/notification.background.spec.ts | 12 ++++++++++-- .../autofill/background/notification.background.ts | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 0be6e5c0ac1..7d33d79a697 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -1727,7 +1727,7 @@ describe("NotificationBackground", () => { expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); }); - it("and no cipher update candidates match `password` or `newPassword`, do not trigger a notification", async () => { + it("and no cipher update candidates match `password` or `newPassword`, trigger a new cipher notification", async () => { const storedCiphersForURL = [ mock({ id: "cipher-id-1", @@ -1745,7 +1745,15 @@ describe("NotificationBackground", () => { await notificationBackground.triggerCipherNotification(formEntryData, tab); expect(pushChangePasswordToQueueSpy).not.toHaveBeenCalled(); - expect(pushAddLoginToQueueSpy).not.toHaveBeenCalled(); + expect(pushAddLoginToQueueSpy).toHaveBeenCalledWith( + mockFormattedURI, + { + password: formEntryData.newPassword, + url: formEntryData.uri, + username: formEntryData.username, + }, + sender.tab, + ); }); }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 33d65391c25..e97672c1f0d 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -992,6 +992,7 @@ export default class NotificationBackground { inputScenarios.usernameNewPassword, inputScenarios.usernamePassword, inputScenarios.username, + inputScenarios.passwordNewPassword, ] as InputScenario[] ).includes(inputScenario) && newLoginNotificationIsEnabled From 86907d68c285de3463879b01901b16c0a32cf2b0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 3 Feb 2026 11:11:00 -0500 Subject: [PATCH 126/130] [PM-29600] Rename Tax Client and Add Endpoints for Upgrade and Proration (#18462) * BREAKING CHANGE: rename tax-client and add proration endpoint update * fix(billing)!: rename tax-client in components * feat(billing): Add upgrade endpoint * fix(billing): update preview client error * fix(billing): add billing address to clients * fix(billing): add additional prorated amount of months * fix(billing): update client call parameter * feat(billing): Enhance ProrationPreviewResponse with new plan details --- .../billing/clients/account-billing.client.ts | 25 ++++++++++ apps/web/src/app/billing/clients/index.ts | 2 +- ...ax.client.ts => preview-invoice.client.ts} | 47 +++++++++++++++++-- .../unified-upgrade-dialog.component.ts | 4 +- .../services/upgrade-payment.service.spec.ts | 45 ++++++++++-------- .../services/upgrade-payment.service.ts | 28 ++++++----- .../change-plan-dialog.component.ts | 17 +++---- .../organization-plans.component.ts | 22 +++++---- .../trial-payment-dialog.component.ts | 11 +++-- .../trial-billing-step.component.ts | 4 +- .../trial-billing-step.service.ts | 6 +-- 11 files changed, 146 insertions(+), 65 deletions(-) rename apps/web/src/app/billing/clients/{tax.client.ts => preview-invoice.client.ts} (65%) diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index e520e70bf70..1334ff643dd 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -1,7 +1,9 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { BitwardenSubscription } from "@bitwarden/subscription"; import { @@ -53,4 +55,27 @@ export class AccountBillingClient { const path = `${this.endpoint}/subscription/storage`; await this.apiService.send("PUT", path, { additionalStorageGb }, true, false); }; + + upgradePremiumToOrganization = async ( + organizationName: string, + organizationKey: string, + planTier: ProductTierType, + cadence: SubscriptionCadence, + billingAddress: Pick, + ): Promise => { + const path = `${this.endpoint}/upgrade`; + await this.apiService.send( + "POST", + path, + { + organizationName, + key: organizationKey, + targetProductTierType: planTier, + cadence, + billingAddress, + }, + true, + false, + ); + }; } diff --git a/apps/web/src/app/billing/clients/index.ts b/apps/web/src/app/billing/clients/index.ts index 0251693a3b2..02e0f688d9d 100644 --- a/apps/web/src/app/billing/clients/index.ts +++ b/apps/web/src/app/billing/clients/index.ts @@ -1,4 +1,4 @@ export * from "./organization-billing.client"; export * from "./subscriber-billing.client"; -export * from "./tax.client"; +export * from "./preview-invoice.client"; export * from "./account-billing.client"; diff --git a/apps/web/src/app/billing/clients/tax.client.ts b/apps/web/src/app/billing/clients/preview-invoice.client.ts similarity index 65% rename from apps/web/src/app/billing/clients/tax.client.ts rename to apps/web/src/app/billing/clients/preview-invoice.client.ts index 09debd5a210..16fb1ca0762 100644 --- a/apps/web/src/app/billing/clients/tax.client.ts +++ b/apps/web/src/app/billing/clients/preview-invoice.client.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { BillingAddress } from "@bitwarden/web-vault/app/billing/payment/types"; @@ -16,6 +17,24 @@ class TaxAmountResponse extends BaseResponse implements TaxAmounts { } } +export class ProrationPreviewResponse extends BaseResponse { + tax: number; + total: number; + credit: number; + newPlanProratedMonths: number; + newPlanProratedAmount: number; + + constructor(response: any) { + super(response); + + this.tax = this.getResponseProperty("Tax"); + this.total = this.getResponseProperty("Total"); + this.credit = this.getResponseProperty("Credit"); + this.newPlanProratedMonths = this.getResponseProperty("NewPlanProratedMonths"); + this.newPlanProratedAmount = this.getResponseProperty("NewPlanProratedAmount"); + } +} + export type OrganizationSubscriptionPlan = { tier: "families" | "teams" | "enterprise"; cadence: "annually" | "monthly"; @@ -51,7 +70,7 @@ export interface TaxAmounts { } @Injectable() -export class TaxClient { +export class PreviewInvoiceClient { constructor(private apiService: ApiService) {} previewTaxForOrganizationSubscriptionPurchase = async ( @@ -60,7 +79,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - "/billing/tax/organizations/subscriptions/purchase", + "/billing/preview-invoice/organizations/subscriptions/purchase", { purchase, billingAddress, @@ -82,7 +101,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - `/billing/tax/organizations/${organizationId}/subscription/plan-change`, + `/billing/preview-invoice/organizations/${organizationId}/subscription/plan-change`, { plan, billingAddress, @@ -100,7 +119,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - `/billing/tax/organizations/${organizationId}/subscription/update`, + `/billing/preview-invoice/organizations/${organizationId}/subscription/update`, { update, }, @@ -117,7 +136,7 @@ export class TaxClient { ): Promise => { const json = await this.apiService.send( "POST", - `/billing/tax/premium/subscriptions/purchase`, + `/billing/preview-invoice/premium/subscriptions/purchase`, { additionalStorage, billingAddress, @@ -128,4 +147,22 @@ export class TaxClient { return new TaxAmountResponse(json); }; + + previewProrationForPremiumUpgrade = async ( + planTier: ProductTierType, + billingAddress: Pick, + ): Promise => { + const prorationResponse = await this.apiService.send( + "POST", + `/billing/preview-invoice/premium/subscriptions/upgrade`, + { + targetProductTierType: planTier, + billingAddress, + }, + true, + true, + ); + + return new ProrationPreviewResponse(prorationResponse); + }; } diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts index 222bf77715c..63017760195 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.ts @@ -15,7 +15,7 @@ import { DialogService, } from "@bitwarden/components"; -import { AccountBillingClient, TaxClient } from "../../../clients"; +import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients"; import { BillingServicesModule } from "../../../services"; import { UpgradeAccountComponent } from "../upgrade-account/upgrade-account.component"; import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service"; @@ -74,7 +74,7 @@ export type UnifiedUpgradeDialogParams = { UpgradePaymentComponent, BillingServicesModule, ], - providers: [UpgradePaymentService, AccountBillingClient, TaxClient], + providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient], templateUrl: "./unified-upgrade-dialog.component.html", }) export class UnifiedUpgradeDialogComponent implements OnInit { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 83440646b48..bbb89bd622f 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -21,7 +21,7 @@ import { AccountBillingClient, SubscriberBillingClient, TaxAmounts, - TaxClient, + PreviewInvoiceClient, } from "../../../../clients"; import { BillingAddress, @@ -35,7 +35,7 @@ import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; describe("UpgradePaymentService", () => { const mockOrganizationBillingService = mock(); const mockAccountBillingClient = mock(); - const mockTaxClient = mock(); + const mockPreviewInvoiceClient = mock(); const mockLogService = mock(); const mockSyncService = mock(); const mockOrganizationService = mock(); @@ -112,7 +112,7 @@ describe("UpgradePaymentService", () => { beforeEach(() => { mockReset(mockOrganizationBillingService); mockReset(mockAccountBillingClient); - mockReset(mockTaxClient); + mockReset(mockPreviewInvoiceClient); mockReset(mockLogService); mockReset(mockOrganizationService); mockReset(mockAccountService); @@ -133,7 +133,7 @@ describe("UpgradePaymentService", () => { useValue: mockOrganizationBillingService, }, { provide: AccountBillingClient, useValue: mockAccountBillingClient }, - { provide: TaxClient, useValue: mockTaxClient }, + { provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient }, { provide: LogService, useValue: mockLogService }, { provide: SyncService, useValue: mockSyncService }, { provide: OrganizationService, useValue: mockOrganizationService }, @@ -183,7 +183,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -236,7 +236,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -271,7 +271,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -307,7 +307,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -333,7 +333,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -389,7 +389,7 @@ describe("UpgradePaymentService", () => { const service = new UpgradePaymentService( mockOrganizationBillingService, mockAccountBillingClient, - mockTaxClient, + mockPreviewInvoiceClient, mockLogService, mockSyncService, mockOrganizationService, @@ -412,17 +412,18 @@ describe("UpgradePaymentService", () => { const mockResponse = mock(); mockResponse.tax = 2.5; - mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue(mockResponse); + mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockResolvedValue( + mockResponse, + ); // Act const result = await sut.calculateEstimatedTax(mockPremiumPlanDetails, mockBillingAddress); // Assert expect(result).toEqual(2.5); - expect(mockTaxClient.previewTaxForPremiumSubscriptionPurchase).toHaveBeenCalledWith( - 0, - mockBillingAddress, - ); + expect( + mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase, + ).toHaveBeenCalledWith(0, mockBillingAddress); }); it("should calculate tax for families plan", async () => { @@ -430,14 +431,18 @@ describe("UpgradePaymentService", () => { const mockResponse = mock(); mockResponse.tax = 5.0; - mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue(mockResponse); + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockResolvedValue( + mockResponse, + ); // Act const result = await sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress); // Assert expect(result).toEqual(5.0); - expect(mockTaxClient.previewTaxForOrganizationSubscriptionPurchase).toHaveBeenCalledWith( + expect( + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase, + ).toHaveBeenCalledWith( { cadence: "annually", tier: "families", @@ -454,7 +459,7 @@ describe("UpgradePaymentService", () => { it("should throw and log error if personal tax calculation fails", async () => { // Arrange const error = new Error("Tax service error"); - mockTaxClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error); + mockPreviewInvoiceClient.previewTaxForPremiumSubscriptionPurchase.mockRejectedValue(error); // Act & Assert await expect( @@ -466,7 +471,9 @@ describe("UpgradePaymentService", () => { it("should throw and log error if organization tax calculation fails", async () => { // Arrange const error = new Error("Tax service error"); - mockTaxClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue(error); + mockPreviewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase.mockRejectedValue( + error, + ); // Act & Assert await expect( sut.calculateEstimatedTax(mockFamiliesPlanDetails, mockBillingAddress), diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index b8d5637e471..06c28123848 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -26,7 +26,7 @@ import { OrganizationSubscriptionPurchase, SubscriberBillingClient, TaxAmounts, - TaxClient, + PreviewInvoiceClient, } from "../../../../clients"; import { BillingAddress, @@ -58,7 +58,7 @@ export class UpgradePaymentService { constructor( private organizationBillingService: OrganizationBillingServiceAbstraction, private accountBillingClient: AccountBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private logService: LogService, private syncService: SyncService, private organizationService: OrganizationService, @@ -101,7 +101,7 @@ export class UpgradePaymentService { const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families; const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium; - let taxClientCall: Promise | null = null; + let previewInvoiceClientCall: Promise | null = null; if (isFamiliesPlan) { // Currently, only Families plan is supported for organization plans @@ -111,22 +111,26 @@ export class UpgradePaymentService { passwordManager: { seats: 1, additionalStorage: 0, sponsored: false }, }; - taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase( - request, + previewInvoiceClientCall = + this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( + request, + billingAddress, + ); + } + + if (isPremiumPlan) { + previewInvoiceClientCall = this.previewInvoiceClient.previewTaxForPremiumSubscriptionPurchase( + 0, billingAddress, ); } - if (isPremiumPlan) { - taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress); - } - - if (taxClientCall === null) { - throw new Error("Tax client call is not defined"); + if (previewInvoiceClientCall === null) { + throw new Error("Preview client call is not defined"); } try { - const preview = await taxClientCall; + const preview = await previewInvoiceClientCall; return preview.tax; } catch (error) { this.logService.error("Tax calculation failed:", error); diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts index d14f627127a..0a22ef5ddac 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.ts @@ -50,7 +50,7 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationSubscriptionPlan, SubscriberBillingClient, - TaxClient, + PreviewInvoiceClient, } from "@bitwarden/web-vault/app/billing/clients"; import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services"; import { @@ -117,7 +117,7 @@ interface OnSuccessArgs { EnterBillingAddressComponent, CardComponent, ], - providers: [SubscriberBillingClient, TaxClient], + providers: [SubscriberBillingClient, PreviewInvoiceClient], }) export class ChangePlanDialogComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -248,7 +248,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { private accountService: AccountService, private billingNotificationService: BillingNotificationService, private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private organizationWarningsService: OrganizationWarningsService, private configService: ConfigService, ) {} @@ -1068,11 +1068,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { ? getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress) : this.billingAddress; - const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( - this.organizationId, - getPlanFromLegacyEnum(this.selectedPlan.type), - billingAddress, - ); + const taxAmounts = + await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange( + this.organizationId, + getPlanFromLegacyEnum(this.selectedPlan.type), + billingAddress, + ); this.estimatedTax = taxAmounts.tax; } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 67f6f9b0a6b..3364ce2cbea 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -52,8 +52,8 @@ import { KeyService } from "@bitwarden/key-management"; import { OrganizationSubscriptionPlan, OrganizationSubscriptionPurchase, + PreviewInvoiceClient, SubscriberBillingClient, - TaxClient, } from "@bitwarden/web-vault/app/billing/clients"; import { EnterBillingAddressComponent, @@ -87,7 +87,7 @@ const Allowed2020PlansForLegacyProviders = [ EnterPaymentMethodComponent, EnterBillingAddressComponent, ], - providers: [SubscriberBillingClient, TaxClient], + providers: [SubscriberBillingClient, PreviewInvoiceClient], }) export class OrganizationPlansComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -219,7 +219,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private toastService: ToastService, private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); @@ -793,11 +793,11 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { // by comparing tax on base+storage vs tax on base only //TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585 const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([ - this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( this.buildTaxPreviewRequest(0, false), billingAddress, ), - this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false), billingAddress, ), @@ -806,10 +806,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { // Tax on storage = Tax on (base + storage) - Tax on (base only) this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax; } else { - const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( - this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview), - billingAddress, - ); + const taxAmounts = + await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest( + this.formGroup.value.additionalStorage, + sponsoredForTaxPreview, + ), + billingAddress, + ); this.estimatedTax = taxAmounts.tax; } diff --git a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts index 64af7be948e..19ccbf28ee9 100644 --- a/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts +++ b/apps/web/src/app/billing/shared/trial-payment-dialog/trial-payment-dialog.component.ts @@ -34,7 +34,10 @@ import { DialogService, ToastService, } from "@bitwarden/components"; -import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { + SubscriberBillingClient, + PreviewInvoiceClient, +} from "@bitwarden/web-vault/app/billing/clients"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, @@ -73,7 +76,7 @@ interface OnSuccessArgs { selector: "app-trial-payment-dialog", templateUrl: "./trial-payment-dialog.component.html", standalone: false, - providers: [SubscriberBillingClient, TaxClient], + providers: [SubscriberBillingClient, PreviewInvoiceClient], }) export class TrialPaymentDialogComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -118,7 +121,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy { private toastService: ToastService, private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction, private subscriberBillingClient: SubscriberBillingClient, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, ) { this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card; } @@ -300,7 +303,7 @@ export class TrialPaymentDialogComponent implements OnInit, OnDestroy { const tier = getTierFromLegacyEnum(this.organization); if (tier && cadence) { - const costs = await this.taxClient.previewTaxForOrganizationSubscriptionPlanChange( + const costs = await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPlanChange( this.organization.id, { tier, diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts index 04ee7931cf3..9b86a9ba81b 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.component.ts @@ -15,7 +15,7 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ToastService } from "@bitwarden/components"; -import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients"; import { BillingAddressControls, EnterBillingAddressComponent, @@ -41,7 +41,7 @@ export interface OrganizationCreatedEvent { selector: "app-trial-billing-step", templateUrl: "./trial-billing-step.component.html", imports: [EnterPaymentMethodComponent, EnterBillingAddressComponent, SharedModule], - providers: [TaxClient, TrialBillingStepService], + providers: [PreviewInvoiceClient, TrialBillingStepService], }) export class TrialBillingStepComponent implements OnInit, OnDestroy { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals diff --git a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts index 0888ef07afc..99eaf5c7988 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service.ts @@ -12,7 +12,7 @@ import { import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { TaxClient } from "@bitwarden/web-vault/app/billing/clients"; +import { PreviewInvoiceClient } from "@bitwarden/web-vault/app/billing/clients"; import { BillingAddressControls, getBillingAddressFromControls, @@ -63,7 +63,7 @@ export class TrialBillingStepService { private accountService: AccountService, private apiService: ApiService, private organizationBillingService: OrganizationBillingServiceAbstraction, - private taxClient: TaxClient, + private previewInvoiceClient: PreviewInvoiceClient, private configService: ConfigService, ) {} @@ -129,7 +129,7 @@ export class TrialBillingStepService { total: number; }> => { const billingAddress = getBillingAddressFromControls(billingAddressControls); - return await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + return await this.previewInvoiceClient.previewTaxForOrganizationSubscriptionPurchase( { tier, cadence, From 1e0b64a55b1b4c09422252596dd128f97d7c96c9 Mon Sep 17 00:00:00 2001 From: Mike Amirault Date: Tue, 3 Feb 2026 11:15:46 -0500 Subject: [PATCH 127/130] [PM-31430] Add specific messages for creating password and email protected Sends (#18692) * [PM-31430] Add specific messages for creating password and email protected Sends * [PM-31430] Fix tests, one bug in Send success drawer component --- .../send-success-drawer-dialog.component.html | 10 +- ...nd-success-drawer-dialog.component.spec.ts | 162 ++++++++++++++++++ .../send-success-drawer-dialog.component.ts | 9 +- apps/web/src/locales/en/messages.json | 20 +++ 4 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html index 90210df4658..ce5b0e36728 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.html @@ -1,6 +1,6 @@ - {{ dialogTitle() | i18n }} + {{ dialogTitle | i18n }}

    - {{ "sendCreatedDescriptionV2" | i18n: formattedExpirationTime }} + @let translationKey = + send.authType === AuthType.Email + ? "sendCreatedDescriptionEmail" + : send.authType === AuthType.Password + ? "sendCreatedDescriptionPassword" + : "sendCreatedDescriptionV2"; + {{ translationKey | i18n: formattedExpirationTime }}

    diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts new file mode 100644 index 00000000000..bfc35f208ed --- /dev/null +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.spec.ts @@ -0,0 +1,162 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { + DIALOG_DATA, + DialogModule, + I18nMockService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { SharedModule } from "@bitwarden/web-vault/app/shared"; + +import { SendSuccessDrawerDialogComponent } from "./send-success-drawer-dialog.component"; + +describe("SendSuccessDrawerDialogComponent", () => { + let fixture: ComponentFixture; + let component: SendSuccessDrawerDialogComponent; + let environmentService: MockProxy; + let platformUtilsService: MockProxy; + let toastService: MockProxy; + + let sendView: SendView; + + // Translation Keys + const newTextSend = "New Text Send"; + const newFileSend = "New File Send"; + const oneHour = "1 hour"; + const oneDay = "1 day"; + const sendCreatedSuccessfully = "Send has been created successfully"; + const sendCreatedDescriptionV2 = "Send ready to share with anyone"; + const sendCreatedDescriptionEmail = "Email-verified Send ready to share"; + const sendCreatedDescriptionPassword = "Password-protected Send ready to share"; + + beforeEach(async () => { + environmentService = mock(); + platformUtilsService = mock(); + toastService = mock(); + + sendView = { + id: "test-send-id", + authType: AuthType.None, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + type: SendType.Text, + accessId: "abc", + urlB64Key: "123", + } as SendView; + + Object.defineProperty(environmentService, "environment$", { + configurable: true, + get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), + }); + + await TestBed.configureTestingModule({ + imports: [SharedModule, DialogModule, TypographyModule], + providers: [ + { + provide: DIALOG_DATA, + useValue: sendView, + }, + { provide: EnvironmentService, useValue: environmentService }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + newTextSend, + newFileSend, + sendCreatedSuccessfully, + sendCreatedDescriptionEmail, + sendCreatedDescriptionPassword, + sendCreatedDescriptionV2, + sendLink: "Send link", + copyLink: "Copy Send Link", + close: "Close", + oneHour, + durationTimeHours: (hours) => `${hours} hours`, + oneDay, + days: (days) => `${days} days`, + loading: "loading", + }); + }, + }, + { provide: PlatformUtilsService, useValue: platformUtilsService }, + { provide: ToastService, useValue: toastService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendSuccessDrawerDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should have the correct title for text Sends", () => { + sendView.type = SendType.Text; + fixture.detectChanges(); + expect(component.dialogTitle).toBe("newTextSend"); + }); + + it("should have the correct title for file Sends", () => { + fixture.componentInstance.send.type = SendType.File; + fixture.detectChanges(); + expect(component.dialogTitle).toBe("newFileSend"); + }); + + it("should show the correct message for Sends with an expiration time of one hour from now", () => { + sendView.deletionDate = new Date(Date.now() + 1 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(oneHour); + }); + + it("should show the correct message for Sends with an expiration time more than an hour but less than a day from now", () => { + const numHours = 8; + sendView.deletionDate = new Date(Date.now() + numHours * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(`${numHours} hours`); + }); + + it("should have the correct title for Sends with an expiration time of one day from now", () => { + sendView.deletionDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(oneDay); + }); + + it("should have the correct title for Sends with an expiration time of multiple days from now", () => { + const numDays = 3; + sendView.deletionDate = new Date(Date.now() + numDays * 24 * 60 * 60 * 1000); + fixture.detectChanges(); + expect(component.formattedExpirationTime).toBe(`${numDays} days`); + }); + + it("should show the correct message for successfully-created Sends with no authentication", () => { + sendView.authType = AuthType.None; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionV2); + }); + + it("should show the correct message for successfully-created Sends with password authentication", () => { + sendView.authType = AuthType.Password; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionPassword); + }); + + it("should show the correct message for successfully-created Sends with email authentication", () => { + sendView.authType = AuthType.Email; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain(sendCreatedSuccessfully); + expect(fixture.nativeElement.textContent).toContain(sendCreatedDescriptionEmail); + }); +}); diff --git a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts index 67e01cd9ff0..9d812bc77ba 100644 --- a/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts +++ b/apps/web/src/app/tools/send/shared/send-success-drawer-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, Inject, signal, computed } from "@angular/core"; +import { Component, ChangeDetectionStrategy, Inject, signal } from "@angular/core"; import { firstValueFrom } from "rxjs"; import { ActiveSendIcon } from "@bitwarden/assets/svg"; @@ -6,6 +6,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { DIALOG_DATA, DialogModule, ToastService, TypographyModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -16,13 +17,13 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SendSuccessDrawerDialogComponent { + readonly AuthType = AuthType; readonly sendLink = signal(""); activeSendIcon = ActiveSendIcon; - // Computed property to get the dialog title based on send type - readonly dialogTitle = computed(() => { + get dialogTitle(): string { return this.send.type === SendType.Text ? "newTextSend" : "newFileSend"; - }); + } constructor( @Inject(DIALOG_DATA) public send: SendView, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index fe93d419035..a894b328d56 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5675,6 +5675,26 @@ } } }, + "sendCreatedDescriptionPassword": { + "message": "Copy and share this Send link. The Send will be available to anyone with the link and password you set 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, 1 hour, 1 day" + } + } + }, + "sendCreatedDescriptionEmail": { + "message": "Copy and share this Send link. It can be viewed by the people you specified 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, 1 hour, 1 day" + } + } + }, "durationTimeHours": { "message": "$HOURS$ hours", "placeholders": { From 11e6b434e30043f088983da18dbc8223b2c7e846 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:59:55 -0800 Subject: [PATCH 128/130] Fix bytes crate vuln RUSTSEC-2026-0007 (#18737) --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 6dab7721f6d..e5c197ef51c 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -512,9 +512,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index f63b09de7ff..b3fac851026 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -27,7 +27,7 @@ ashpd = "=0.12.0" base64 = "=0.22.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" -bytes = "=1.11.0" +bytes = "=1.11.1" cbc = "=0.1.2" chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" From 2d85b62bebe213d8f168a6b097867f8056cf7877 Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Tue, 3 Feb 2026 12:18:10 -0500 Subject: [PATCH 129/130] PM-31247 interchanged error message (#18644) --- .../send-form/components/send-details/send-details.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts index 463f3195645..46eded5e86d 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.ts @@ -312,7 +312,7 @@ export class SendDetailsComponent implements OnInit { const emails = control.value.split(",").map((e: string) => e.trim()); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const invalidEmails = emails.filter((e: string) => e.length > 0 && !emailRegex.test(e)); - return invalidEmails.length > 0 ? { email: true } : null; + return invalidEmails.length > 0 ? { multipleEmails: true } : null; }; } From 38465c059c297f8d52dffdf73d6990cfd4b10ce6 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Tue, 3 Feb 2026 12:47:58 -0500 Subject: [PATCH 130/130] [PM-29602] Update Cart Summary for Upgrade Flow (#18605) * feat(billing): update cart-summary logic Add functionality to hide breakdown and allow translation params * tests(cart-summary): update tests and stories * feat(pricing): Add quantity support to discount labels * feat(pricing): discount quantity story * Revert "feat(pricing): discount quantity story" This reverts commit 2c00891f1fbb654954d58483d4dfdb720b5d9348. * Revert "feat(pricing): Add quantity support to discount labels" This reverts commit 8350fdd90f0de7f0d7675cd1be5a22cba34ed3fe. * fix(cart-summary): Adjust discount text styling * feat(pricing): adds support for hidden discount amounts Allows hiding the formatted amount for discounts in the cart summary. This is useful for scenarios where the discount amount is displayed elsewhere or is not relevant to the user. Updates the storybook to include a story demonstrating this feature. * feat(pricing): conditionally format currency amounts to show or hide decimals * Revert "feat(pricing): adds support for hidden discount amounts" This reverts commit 076724276c05a4463f05aa50fc119f5058dc2324. * Revert "fix(cart-summary): Adjust discount text styling" This reverts commit d02c12fc2a11b3e050bf59ba85525d8f066bd446. * Revert "discount translation" * feat(pricing): add credit type to cart summary * feat(pricing-card): Add i18n and icon component infrastructure * feat(pricing-card): Apply i18n pipe to pricing card template * refactor(pricing-card): Replace `` tags with `` in template * test(pricing-card): Update tests for i18n and icon component changes * docs(pricing-card): Enhance Storybook and documentation for new features * feat(pricing-card): Adds "per user" translation key * refactor(pricing-card): use property binding for bit-icon name * docs(pricing-card): expand price cadence options in MDX * fix(icon): update exports for icon types * feat(billing): Use strongly typed BitwardenIcon for pricing card buttons * refactor(pricing): Remove unused I18nService from PricingCardComponent * fix(pricing): Improve pricing card button icon template null-safety * fix(pricing-card): format update Clarifies the description of the `price` property within the PricingCard component documentation. No functional code changes are included. * refactor: Update discount label typography in cart summary * refactor(stories): Rename account credit translation key to premium subscription credit * feat(pricing-card): update spacing for card without button --- apps/web/src/locales/en/messages.json | 3 + .../subscription-pricing-card-details.ts | 8 +- libs/components/src/index.ts | 1 + .../cart-summary/cart-summary.component.html | 122 +++++++--- .../cart-summary/cart-summary.component.mdx | 78 ++++++- .../cart-summary.component.spec.ts | 212 ++++++++++++++++++ .../cart-summary.component.stories.ts | 91 ++++++++ .../cart-summary/cart-summary.component.ts | 23 +- .../pricing-card/pricing-card.component.html | 32 +-- .../pricing-card/pricing-card.component.mdx | 54 ++++- .../pricing-card.component.spec.ts | 26 ++- .../pricing-card.component.stories.ts | 43 +++- .../pricing-card/pricing-card.component.ts | 18 +- libs/pricing/src/types/cart.ts | 5 + libs/pricing/src/types/credit.ts | 5 + 15 files changed, 662 insertions(+), 59 deletions(-) create mode 100644 libs/pricing/src/types/credit.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a894b328d56..04566a666d4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12782,5 +12782,8 @@ }, "invalidSendPassword": { "message": "Invalid Send password" + }, + "perUser": { + "message": "per user" } } diff --git a/libs/angular/src/billing/types/subscription-pricing-card-details.ts b/libs/angular/src/billing/types/subscription-pricing-card-details.ts index 9000b10a729..5f37f91c4f0 100644 --- a/libs/angular/src/billing/types/subscription-pricing-card-details.ts +++ b/libs/angular/src/billing/types/subscription-pricing-card-details.ts @@ -1,10 +1,14 @@ import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier"; -import { ButtonType } from "@bitwarden/components"; +import { BitwardenIcon, ButtonType } from "@bitwarden/components"; export type SubscriptionPricingCardDetails = { title: string; tagline: string; price?: { amount: number; cadence: SubscriptionCadence }; - button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } }; + button: { + text: string; + type: ButtonType; + icon?: { type: BitwardenIcon; position: "before" | "after" }; + }; features: string[]; }; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 7395b87b2ab..d92e0770e49 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,4 +1,5 @@ export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction"; +export { BitwardenIcon } from "./shared/icon"; export * from "./a11y"; export * from "./anon-layout"; export * from "./async-actions"; diff --git a/libs/pricing/src/components/cart-summary/cart-summary.component.html b/libs/pricing/src/components/cart-summary/cart-summary.component.html index e916de3995d..d3a0ad25e6c 100644 --- a/libs/pricing/src/components/cart-summary/cart-summary.component.html +++ b/libs/pricing/src/components/cart-summary/cart-summary.component.html @@ -16,7 +16,7 @@ {{ "total" | i18n }}: {{ total() | currency: "USD" : "symbol" }} USD

  - / {{ term }} + / {{ term }} }
- } -
+
+ }
@@ -67,10 +69,12 @@
    @for (feature of featureList; track feature) {
  • - + > + {{ feature }} diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx index 905b8e6981f..1cbac94d8ee 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx @@ -39,7 +39,7 @@ import { PricingCardComponent } from "@bitwarden/pricing"; | Input | Type | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) | -| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | +| `price` | `{ amount: number; cadence: "month" \| "monthly" \| "year" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | | `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" | | `features` | `string[]` | **Optional.** List of features with checkmarks | | `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown | @@ -182,6 +182,58 @@ For coming soon or unavailable plans: ``` +### With Button Icons + +Add icons to buttons for enhanced visual communication: + + + +```html + + + + + + + +``` + +### Active Plan Badge + +Show which plan is currently active: + + + +```html + + +``` + ### Pricing Grid Layout Multiple cards displayed together: diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts index 669b54c5b57..fc8a9541952 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BadgeVariant, ButtonType, SvgModule, TypographyModule } from "@bitwarden/components"; import { PricingCardComponent } from "@bitwarden/pricing"; @@ -69,6 +70,29 @@ describe("PricingCardComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [PricingCardComponent, TestHostComponent, SvgModule, TypographyModule, CommonModule], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "month": + return "month"; + case "monthly": + return "monthly"; + case "year": + return "year"; + case "annually": + return "annually"; + case "perUser": + return "per user"; + default: + return key; + } + }, + }, + }, + ], }).compileComponents(); // For signal inputs, we need to set required inputs through the host component @@ -151,7 +175,7 @@ describe("PricingCardComponent", () => { it("should display bwi-check icons for features", () => { hostFixture.detectChanges(); const compiled = hostFixture.nativeElement; - const icons = compiled.querySelectorAll("i.bwi-check"); + const icons = compiled.querySelectorAll("bit-icon[name='bwi-check']"); expect(icons.length).toBe(3); // One for each feature }); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts index 832345de357..63946cbf19a 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts @@ -1,15 +1,42 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; -import { TypographyModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SvgModule, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { PricingCardComponent } from "./pricing-card.component"; export default { title: "Billing/Pricing Card", component: PricingCardComponent, - moduleMetadata: { - imports: [TypographyModule], - }, + decorators: [ + moduleMetadata({ + imports: [PricingCardComponent, SvgModule, TypographyModule, I18nPipe], + providers: [ + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "month": + return "month"; + case "monthly": + return "monthly"; + case "year": + return "year"; + case "annually": + return "annually"; + case "perUser": + return "per user"; + default: + return key; + } + }, + }, + }, + ], + }), + ], args: { tagline: "Everything you need for secure password management across all your devices", }, @@ -83,7 +110,7 @@ export const WithoutFeatures: Story = { }), args: { tagline: "Advanced security and management for your organization", - price: { amount: 3, cadence: "monthly" }, + price: { amount: 3, cadence: "month" }, button: { text: "Contact Sales", type: "primary" }, }, }; @@ -150,7 +177,7 @@ export const LongTagline: Story = { args: { tagline: "Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business", - price: { amount: 5, cadence: "monthly", showPerUser: true }, + price: { amount: 5, cadence: "month", showPerUser: true }, button: { text: "Start Business Trial", type: "primary" }, features: [ "Everything in Premium", @@ -274,7 +301,7 @@ export const WithoutButton: Story = { }), args: { tagline: "This plan will be available soon with exciting new features", - price: { amount: 15, cadence: "monthly" }, + price: { amount: 15, cadence: "month" }, features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"], }, }; diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts index 4b9241fc9dd..23eda0fa99b 100644 --- a/libs/pricing/src/components/pricing-card/pricing-card.component.ts +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -4,12 +4,15 @@ import { ChangeDetectionStrategy, Component, input, output } from "@angular/core import { BadgeModule, BadgeVariant, + BitwardenIcon, ButtonModule, ButtonType, CardComponent, + IconModule, SvgModule, TypographyModule, } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; /** * A reusable UI-only component that displays pricing information in a card format. @@ -20,20 +23,29 @@ import { selector: "billing-pricing-card", templateUrl: "./pricing-card.component.html", changeDetection: ChangeDetectionStrategy.OnPush, - imports: [BadgeModule, ButtonModule, SvgModule, TypographyModule, CurrencyPipe, CardComponent], + imports: [ + BadgeModule, + ButtonModule, + SvgModule, + IconModule, + TypographyModule, + CurrencyPipe, + CardComponent, + I18nPipe, + ], }) export class PricingCardComponent { readonly tagline = input.required(); readonly price = input<{ amount: number; - cadence: "monthly" | "annually"; + cadence: "month" | "monthly" | "year" | "annually"; showPerUser?: boolean; }>(); readonly button = input<{ type: ButtonType; text: string; disabled?: boolean; - icon?: { type: string; position: "before" | "after" }; + icon?: { type: BitwardenIcon; position: "before" | "after" }; }>(); readonly features = input(); readonly activeBadge = input<{ text: string; variant?: BadgeVariant }>(); diff --git a/libs/pricing/src/types/cart.ts b/libs/pricing/src/types/cart.ts index ed5108edee8..aeec6b269af 100644 --- a/libs/pricing/src/types/cart.ts +++ b/libs/pricing/src/types/cart.ts @@ -1,10 +1,14 @@ import { Discount } from "@bitwarden/pricing"; +import { Credit } from "./credit"; + export type CartItem = { translationKey: string; + translationParams?: Array; quantity: number; cost: number; discount?: Discount; + hideBreakdown?: boolean; }; export type Cart = { @@ -18,5 +22,6 @@ export type Cart = { }; cadence: "annually" | "monthly"; discount?: Discount; + credit?: Credit; estimatedTax: number; }; diff --git a/libs/pricing/src/types/credit.ts b/libs/pricing/src/types/credit.ts new file mode 100644 index 00000000000..bb7e42bcb62 --- /dev/null +++ b/libs/pricing/src/types/credit.ts @@ -0,0 +1,5 @@ +export type Credit = { + translationKey: string; + translationParams?: Array; + value: number; +};