From 8ed8c9af6a878abdcbf414169932697c6def8dd1 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 24 Mar 2025 08:33:17 -0500 Subject: [PATCH 001/228] [PM-18685] Disable MyVault when the Person Ownership policy is true (#13930) --- .../src/components/export.component.html | 18 ++++-- .../src/components/export.component.ts | 57 +++++++++++++++---- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index 7555b206976..5b196f51799 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -1,17 +1,23 @@ - + {{ "personalVaultExportPolicyInEffect" | i18n }} - +
{{ "exportFrom" | i18n }} - + ; + disablePersonalOwnershipPolicy$: Observable; + exportForm = this.formBuilder.group({ vaultSelector: [ "myVault", @@ -201,15 +205,13 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { this.formDisabled.emit(c === "DISABLED"); }); - this.policyService - .policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport) - .pipe(takeUntil(this.destroy$)) - .subscribe((policyAppliesToActiveUser) => { - this._disabledByPolicy = policyAppliesToActiveUser; - if (this.disabledByPolicy) { - this.exportForm.disable(); - } - }); + // policies + this.disablePersonalVaultExportPolicy$ = this.policyService.policyAppliesToActiveUser$( + PolicyType.DisablePersonalVaultExport, + ); + this.disablePersonalOwnershipPolicy$ = this.policyService.policyAppliesToActiveUser$( + PolicyType.PersonalOwnership, + ); merge( this.exportForm.get("format").valueChanges, @@ -269,13 +271,45 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { }), ); + combineLatest([ + this.disablePersonalVaultExportPolicy$, + this.disablePersonalOwnershipPolicy$, + this.organizations$, + ]) + .pipe( + tap(([disablePersonalVaultExport, disablePersonalOwnership, organizations]) => { + this._disabledByPolicy = disablePersonalVaultExport; + + // When personalOwnership is disabled and we have orgs, set the first org as the selected vault + if (disablePersonalOwnership && organizations.length > 0) { + this.exportForm.enable(); + this.exportForm.controls.vaultSelector.setValue(organizations[0].id); + } + + // When personalOwnership is disabled and we have no orgs, disable the form + if (disablePersonalOwnership && organizations.length === 0) { + this.exportForm.disable(); + } + + // When personalVaultExport is disabled, disable the form + if (disablePersonalVaultExport) { + this.exportForm.disable(); + } + + // When neither policy is enabled, enable the form and set the default vault to "myVault" + if (!disablePersonalVaultExport && !disablePersonalOwnership) { + this.exportForm.controls.vaultSelector.setValue("myVault"); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + this.exportForm.controls.vaultSelector.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((value) => { this.organizationId = value != "myVault" ? value : undefined; }); - - this.exportForm.controls.vaultSelector.setValue("myVault"); } ngAfterViewInit(): void { @@ -286,6 +320,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { ngOnDestroy(): void { this.destroy$.next(); + this.destroy$.complete(); } get encryptedFormat() { From 8e62e0589d43795b9f48ced58d95354bb0328253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Mon, 24 Mar 2025 20:17:18 +0100 Subject: [PATCH 002/228] PM-11230: Add hybrid as passkey transport (#13536) --- .../common/src/platform/services/fido2/fido2-client.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 4bf30ef6537..2445cd366de 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -251,7 +251,8 @@ export class Fido2ClientService clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes), publicKey: Fido2Utils.bufferToString(makeCredentialResult.publicKey), publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm, - transports: params.rp.id === "google.com" ? ["internal", "usb"] : ["internal"], + transports: + params.rp.id === "google.com" ? ["internal", "usb", "hybrid"] : ["internal", "hybrid"], extensions: { credProps }, }; } From 8c6a33d7b87a59d341a02728aba97a32d8b3801a Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 24 Mar 2025 20:41:21 +0100 Subject: [PATCH 003/228] [PM-16603] Implement userkey rotation v2 (#12646) * Implement key rotation v2 * Pass through masterpassword hint * Properly split old and new code * Mark legacy rotation as deprecated * Throw when data is null * Cleanup * Add tests * Fix build * Update libs/key-management/src/key.service.spec.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Update apps/web/src/app/auth/settings/change-password.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Add documentation * Centralize loading logic * Fix build * Remove sharedlib from legacymigration component --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> --- .../settings/change-password.component.html | 2 +- .../settings/change-password.component.ts | 135 ++++++++++++- .../request/account-keys.request.ts | 10 + .../master-password-unlock-data.request.ts | 35 ++++ .../rotate-user-account-keys.request.ts | 29 +++ .../request/unlock-data.request.ts | 26 +++ .../key-rotation/request/userdata.request.ts | 19 ++ .../user-key-rotation-api.service.ts | 11 ++ .../user-key-rotation.service.spec.ts | 124 ++++++++++-- .../key-rotation/user-key-rotation.service.ts | 181 +++++++++++++++++- .../migrate-legacy-encryption.component.ts | 2 +- .../user-verification.service.spec.ts | 4 + .../user-verification.service.ts | 2 +- libs/common/src/auth/types/verification.ts | 4 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/abstractions/key.service.ts | 11 ++ libs/key-management/src/key.service.spec.ts | 49 ++++- libs/key-management/src/key.service.ts | 15 ++ 18 files changed, 642 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts create mode 100644 apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts create mode 100644 apps/web/src/app/key-management/key-rotation/request/rotate-user-account-keys.request.ts create mode 100644 apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts create mode 100644 apps/web/src/app/key-management/key-rotation/request/userdata.request.ts diff --git a/apps/web/src/app/auth/settings/change-password.component.html b/apps/web/src/app/auth/settings/change-password.component.html index 91144fdfc1f..34bb74ee473 100644 --- a/apps/web/src/app/auth/settings/change-password.component.html +++ b/apps/web/src/app/auth/settings/change-password.component.html @@ -121,7 +121,7 @@ [(ngModel)]="masterPasswordHint" /> - diff --git a/apps/web/src/app/auth/settings/change-password.component.ts b/apps/web/src/app/auth/settings/change-password.component.ts index 5f061dd1e2a..d8e371fd36b 100644 --- a/apps/web/src/app/auth/settings/change-password.component.ts +++ b/apps/web/src/app/auth/settings/change-password.component.ts @@ -12,7 +12,9 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { InternalMasterPasswordServiceAbstraction } 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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -35,11 +37,13 @@ export class ChangePasswordComponent extends BaseChangePasswordComponent implements OnInit, OnDestroy { + loading = false; rotateUserKey = false; currentMasterPassword: string; masterPasswordHint: string; checkForBreaches = true; characterMinimumMessage = ""; + userkeyRotationV2 = false; constructor( i18nService: I18nService, @@ -56,9 +60,10 @@ export class ChangePasswordComponent private userVerificationService: UserVerificationService, private keyRotationService: UserKeyRotationService, kdfConfigService: KdfConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, accountService: AccountService, toastService: ToastService, + private configService: ConfigService, ) { super( i18nService, @@ -75,6 +80,8 @@ export class ChangePasswordComponent } async ngOnInit() { + this.userkeyRotationV2 = await this.configService.getFeatureFlag(FeatureFlag.UserKeyRotationV2); + if (!(await this.userVerificationService.hasMasterPassword())) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -137,6 +144,130 @@ export class ChangePasswordComponent } async submit() { + if (this.userkeyRotationV2) { + this.loading = true; + await this.submitNew(); + this.loading = false; + } else { + await this.submitOld(); + } + } + + async submitNew() { + if (this.currentMasterPassword == null || this.currentMasterPassword === "") { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return; + } + + if ( + this.masterPasswordHint != null && + this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase() + ) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("hintEqualsPassword"), + }); + return; + } + + this.leakedPassword = false; + if (this.checkForBreaches) { + this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0; + } + + if (!(await this.strongPassword())) { + return; + } + + try { + if (this.rotateUserKey) { + await this.syncService.fullSync(true); + const user = await firstValueFrom(this.accountService.activeAccount$); + await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + this.currentMasterPassword, + this.masterPassword, + user, + this.masterPasswordHint, + ); + } else { + await this.updatePassword(this.masterPassword); + } + } catch (e) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: e.message, + }); + } + } + + // todo: move this to a service + // https://bitwarden.atlassian.net/browse/PM-17108 + private async updatePassword(newMasterPassword: string) { + const currentMasterPassword = this.currentMasterPassword; + const { userId, email } = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))), + ); + const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId)); + + const currentMasterKey = await this.keyService.makeMasterKey( + currentMasterPassword, + email, + kdfConfig, + ); + const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + currentMasterKey, + userId, + ); + if (decryptedUserKey == null) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("invalidMasterPassword"), + }); + return; + } + + const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig); + const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( + newMasterKey, + decryptedUserKey, + ); + + const request = new PasswordRequest(); + request.masterPasswordHash = await this.keyService.hashMasterKey( + this.currentMasterPassword, + currentMasterKey, + ); + request.masterPasswordHint = this.masterPasswordHint; + request.newMasterPasswordHash = await this.keyService.hashMasterKey( + newMasterPassword, + newMasterKey, + ); + request.key = newMasterKeyEncryptedUserKey[1].encryptedString; + try { + await this.masterPasswordApiService.postPassword(request); + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("masterPasswordChanged"), + message: this.i18nService.t("masterPasswordChangedDesc"), + }); + this.messagingService.send("logout"); + } catch { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("errorOccurred"), + }); + } + } + + async submitOld() { if ( this.masterPasswordHint != null && this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase() @@ -242,6 +373,6 @@ export class ChangePasswordComponent private async updateKey() { const user = await firstValueFrom(this.accountService.activeAccount$); - await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user); + await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(this.masterPassword, user); } } diff --git a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts new file mode 100644 index 00000000000..1c9b6c9ceca --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts @@ -0,0 +1,10 @@ +export class AccountKeysRequest { + // Other keys encrypted by the userkey + userKeyEncryptedAccountPrivateKey: string; + accountPublicKey: string; + + constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) { + this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey; + this.accountPublicKey = accountPublicKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts b/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts new file mode 100644 index 00000000000..95e54e16464 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/request/master-password-unlock-data.request.ts @@ -0,0 +1,35 @@ +import { Argon2KdfConfig, KdfConfig, KdfType } from "@bitwarden/key-management"; + +export class MasterPasswordUnlockDataRequest { + kdfType: KdfType = KdfType.PBKDF2_SHA256; + kdfIterations: number = 0; + kdfMemory?: number; + kdfParallelism?: number; + + email: string; + masterKeyAuthenticationHash: string; + + masterKeyEncryptedUserKey: string; + + masterPasswordHint?: string; + + constructor( + kdfConfig: KdfConfig, + email: string, + masterKeyAuthenticationHash: string, + masterKeyEncryptedUserKey: string, + masterPasswordHash?: string, + ) { + this.kdfType = kdfConfig.kdfType; + this.kdfIterations = kdfConfig.iterations; + if (kdfConfig.kdfType === KdfType.Argon2id) { + this.kdfMemory = (kdfConfig as Argon2KdfConfig).memory; + this.kdfParallelism = (kdfConfig as Argon2KdfConfig).parallelism; + } + + this.email = email; + this.masterKeyAuthenticationHash = masterKeyAuthenticationHash; + this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey; + this.masterPasswordHint = masterPasswordHash; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/request/rotate-user-account-keys.request.ts b/apps/web/src/app/key-management/key-rotation/request/rotate-user-account-keys.request.ts new file mode 100644 index 00000000000..f335bb66bbb --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/request/rotate-user-account-keys.request.ts @@ -0,0 +1,29 @@ +import { AccountKeysRequest } from "./account-keys.request"; +import { UnlockDataRequest } from "./unlock-data.request"; +import { UserDataRequest as AccountDataRequest } from "./userdata.request"; + +export class RotateUserAccountKeysRequest { + constructor( + accountUnlockData: UnlockDataRequest, + accountKeys: AccountKeysRequest, + accountData: AccountDataRequest, + oldMasterKeyAuthenticationHash: string, + ) { + this.accountUnlockData = accountUnlockData; + this.accountKeys = accountKeys; + this.accountData = accountData; + this.oldMasterKeyAuthenticationHash = oldMasterKeyAuthenticationHash; + } + + // Authentication for the request + oldMasterKeyAuthenticationHash: string; + + // All methods to get to the userkey + accountUnlockData: UnlockDataRequest; + + // Other keys encrypted by the userkey + accountKeys: AccountKeysRequest; + + // User vault data encrypted by the userkey + accountData: AccountDataRequest; +} diff --git a/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts b/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts new file mode 100644 index 00000000000..5cdb56a3e23 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/request/unlock-data.request.ts @@ -0,0 +1,26 @@ +import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; +import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; + +import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request"; + +import { MasterPasswordUnlockDataRequest } from "./master-password-unlock-data.request"; + +export class UnlockDataRequest { + // All methods to get to the userkey + masterPasswordUnlockData: MasterPasswordUnlockDataRequest; + emergencyAccessUnlockData: EmergencyAccessWithIdRequest[]; + organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[]; + passkeyUnlockData: WebauthnRotateCredentialRequest[]; + + constructor( + masterPasswordUnlockData: MasterPasswordUnlockDataRequest, + emergencyAccessUnlockData: EmergencyAccessWithIdRequest[], + organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[], + passkeyUnlockData: WebauthnRotateCredentialRequest[], + ) { + this.masterPasswordUnlockData = masterPasswordUnlockData; + this.emergencyAccessUnlockData = emergencyAccessUnlockData; + this.organizationAccountRecoveryUnlockData = organizationAccountRecoveryUnlockData; + this.passkeyUnlockData = passkeyUnlockData; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/request/userdata.request.ts b/apps/web/src/app/key-management/key-rotation/request/userdata.request.ts new file mode 100644 index 00000000000..02204428686 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/request/userdata.request.ts @@ -0,0 +1,19 @@ +import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; +import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; +import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; + +export class UserDataRequest { + ciphers: CipherWithIdRequest[]; + folders: FolderWithIdRequest[]; + sends: SendWithIdRequest[]; + + constructor( + ciphers: CipherWithIdRequest[], + folders: FolderWithIdRequest[], + sends: SendWithIdRequest[], + ) { + this.ciphers = ciphers; + this.folders = folders; + this.sends = sends; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts index 3c8adc886df..2a947359bcb 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation-api.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request"; import { UpdateKeyRequest } from "./request/update-key.request"; @Injectable() @@ -11,4 +12,14 @@ export class UserKeyRotationApiService { postUserKeyUpdate(request: UpdateKeyRequest): Promise { return this.apiService.send("POST", "/accounts/key", request, true, false); } + + postUserKeyUpdateV2(request: RotateUserAccountKeysRequest): Promise { + return this.apiService.send( + "POST", + "/accounts/key-management/rotate-user-account-keys", + request, + true, + false, + ); + } } diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index 09d35d34da8..4aa0a753f63 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -6,21 +6,25 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; 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 { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; -import { UserKey, UserPrivateKey } from "@bitwarden/common/types/key"; +import { UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request"; import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request"; -import { KeyService } from "@bitwarden/key-management"; +import { ToastService } from "@bitwarden/components"; +import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth/core"; @@ -47,6 +51,9 @@ describe("KeyRotationService", () => { let mockSyncService: MockProxy; let mockWebauthnLoginAdminService: MockProxy; let mockLogService: MockProxy; + let mockVaultTimeoutService: MockProxy; + let mockToastService: MockProxy; + let mockI18nService: MockProxy; const mockUser = { id: "mockUserId" as UserId, @@ -70,6 +77,9 @@ describe("KeyRotationService", () => { mockSyncService = mock(); mockWebauthnLoginAdminService = mock(); mockLogService = mock(); + mockVaultTimeoutService = mock(); + mockToastService = mock(); + mockI18nService = mock(); keyRotationService = new UserKeyRotationService( mockUserVerificationService, @@ -85,6 +95,9 @@ describe("KeyRotationService", () => { mockSyncService, mockWebauthnLoginAdminService, mockLogService, + mockVaultTimeoutService, + mockToastService, + mockI18nService, ); }); @@ -94,6 +107,7 @@ describe("KeyRotationService", () => { describe("rotateUserKeyAndEncryptedData", () => { let privateKey: BehaviorSubject; + let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>; beforeEach(() => { mockKeyService.makeUserKey.mockResolvedValue([ @@ -112,6 +126,8 @@ describe("KeyRotationService", () => { // Mock user verification mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({ masterKey: "mockMasterKey" as any, + kdfConfig: DEFAULT_KDF_CONFIG, + email: "mockEmail", policyOptions: null, }); @@ -122,6 +138,12 @@ describe("KeyRotationService", () => { privateKey = new BehaviorSubject("mockPrivateKey" as any); mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey); + keyPair = new BehaviorSubject({ + privateKey: "mockPrivateKey", + publicKey: "mockPublicKey", + } as any); + mockKeyService.userEncryptionKeyPair$.mockReturnValue(keyPair); + // Mock ciphers const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")]; mockCipherService.getRotatedData.mockResolvedValue(mockCiphers); @@ -147,8 +169,8 @@ describe("KeyRotationService", () => { mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn); }); - it("rotates the user key and encrypted data", async () => { - await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser); + it("rotates the user key and encrypted data legacy", async () => { + await keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser); expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled(); const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0]; @@ -162,9 +184,47 @@ describe("KeyRotationService", () => { expect(arg.webauthnKeys.length).toBe(2); }); + it("rotates the user key and encrypted data", async () => { + await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockNewMasterPassword", + mockUser, + ); + + expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled(); + const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0]; + expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash"); + expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail"); + expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe( + DEFAULT_KDF_CONFIG.kdfType, + ); + expect(arg.accountUnlockData.masterPasswordUnlockData.kdfIterations).toBe( + DEFAULT_KDF_CONFIG.iterations, + ); + expect(arg.accountKeys.accountPublicKey).toBe(Utils.fromUtf8ToB64("mockPublicKey")); + expect(arg.accountKeys.userKeyEncryptedAccountPrivateKey).toBe("mockEncryptedData"); + expect(arg.accountData.ciphers.length).toBe(2); + expect(arg.accountData.folders.length).toBe(2); + expect(arg.accountData.sends.length).toBe(2); + }); + + it("legacy throws if master password provided is falsey", async () => { + await expect( + keyRotationService.rotateUserKeyAndEncryptedDataLegacy("", mockUser), + ).rejects.toThrow(); + }); + it("throws if master password provided is falsey", async () => { await expect( - keyRotationService.rotateUserKeyAndEncryptedData("", mockUser), + keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData("", "", mockUser), + ).rejects.toThrow(); + }); + + it("legacy throws if user key creation fails", async () => { + mockKeyService.makeUserKey.mockResolvedValueOnce([null, null]); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser), ).rejects.toThrow(); }); @@ -175,15 +235,41 @@ describe("KeyRotationService", () => { ]); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), + keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + ), + ).rejects.toThrow(); + }); + + it("legacy throws if no private key is found", async () => { + privateKey.next(null); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser), ).rejects.toThrow(); }); it("throws if no private key is found", async () => { - privateKey.next(null); + keyPair.next(null); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), + keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + ), + ).rejects.toThrow(); + }); + + it("legacy throws if master password is incorrect", async () => { + mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce( + new Error("Invalid master password"), + ); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser), ).rejects.toThrow(); }); @@ -193,15 +279,31 @@ describe("KeyRotationService", () => { ); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), + keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + ), + ).rejects.toThrow(); + }); + + it("legacy throws if server rotation fails", async () => { + mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError")); + + await expect( + keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser), ).rejects.toThrow(); }); it("throws if server rotation fails", async () => { - mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError")); + mockApiService.postUserKeyUpdateV2.mockRejectedValueOnce(new Error("mockError")); await expect( - keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser), + keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + ), ).rejects.toThrow(); }); }); diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index d200fff256c..c1b7a04d62b 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -7,7 +7,11 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -15,13 +19,19 @@ import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth/core"; import { EmergencyAccessService } from "../../auth/emergency-access"; +import { AccountKeysRequest } from "./request/account-keys.request"; +import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request"; +import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request"; +import { UnlockDataRequest } from "./request/unlock-data.request"; import { UpdateKeyRequest } from "./request/update-key.request"; +import { UserDataRequest } from "./request/userdata.request"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; @Injectable() @@ -40,14 +50,180 @@ export class UserKeyRotationService { private syncService: SyncService, private webauthnLoginAdminService: WebauthnLoginAdminService, private logService: LogService, + private vaultTimeoutService: VaultTimeoutService, + private toastService: ToastService, + private i18nService: I18nService, ) {} /** * Creates a new user key and re-encrypts all required data with the it. - * @param masterPassword current master password (used for validation) + * @param oldMasterPassword: The current master password + * @param newMasterPassword: The new master password + * @param user: The user account + * @param newMasterPasswordHint: The hint for the new master password */ - async rotateUserKeyAndEncryptedData(masterPassword: string, user: Account): Promise { + async rotateUserKeyMasterPasswordAndEncryptedData( + oldMasterPassword: string, + newMasterPassword: string, + user: Account, + newMasterPasswordHint?: string, + ): Promise { this.logService.info("[Userkey rotation] Starting user key rotation..."); + if (!newMasterPassword) { + this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!"); + throw new Error("Invalid master password"); + } + + if ((await this.syncService.getLastSync()) === null) { + this.logService.info("[Userkey rotation] Client was never synced. Aborting!"); + throw new Error( + "The local vault is de-synced and the keys cannot be rotated. Please log out and log back in to resolve this issue.", + ); + } + + const { + masterKey: oldMasterKey, + email, + kdfConfig, + } = await this.userVerificationService.verifyUserByMasterPassword( + { + type: VerificationType.MasterPassword, + secret: oldMasterPassword, + }, + user.id, + user.email, + ); + + const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig); + + const [newUnencryptedUserKey, newMasterKeyEncryptedUserKey] = + await this.keyService.makeUserKey(newMasterKey); + + if (!newUnencryptedUserKey || !newMasterKeyEncryptedUserKey) { + this.logService.info("[Userkey rotation] User key could not be created. Aborting!"); + throw new Error("User key could not be created"); + } + + const newMasterKeyAuthenticationHash = await this.keyService.hashMasterKey( + newMasterPassword, + newMasterKey, + HashPurpose.ServerAuthorization, + ); + const masterPasswordUnlockData = new MasterPasswordUnlockDataRequest( + kdfConfig, + email, + newMasterKeyAuthenticationHash, + newMasterKeyEncryptedUserKey.encryptedString!, + newMasterPasswordHint, + ); + + const keyPair = await firstValueFrom(this.keyService.userEncryptionKeyPair$(user.id)); + if (keyPair == null) { + this.logService.info("[Userkey rotation] Key pair is null. Aborting!"); + throw new Error("Key pair is null"); + } + const { privateKey, publicKey } = keyPair; + + const accountKeysRequest = new AccountKeysRequest( + (await this.encryptService.encrypt(privateKey, newUnencryptedUserKey)).encryptedString!, + Utils.fromBufferToB64(publicKey), + ); + + const originalUserKey = await firstValueFrom(this.keyService.userKey$(user.id)); + if (originalUserKey == null) { + this.logService.info("[Userkey rotation] Userkey is null. Aborting!"); + throw new Error("Userkey key is null"); + } + + const rotatedCiphers = await this.cipherService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + const rotatedFolders = await this.folderService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + const rotatedSends = await this.sendService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + if (rotatedCiphers == null || rotatedFolders == null || rotatedSends == null) { + this.logService.info("[Userkey rotation] ciphers, folders, or sends are null. Aborting!"); + throw new Error("ciphers, folders, or sends are null"); + } + const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends); + + const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + // Note: Reset password keys request model has user verification + // properties, but the rotation endpoint uses its own MP hash. + const organizationAccountRecoveryUnlockData = await this.resetPasswordService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + if (organizationAccountRecoveryUnlockData == null) { + this.logService.info( + "[Userkey rotation] Organization account recovery data is null. Aborting!", + ); + throw new Error("Organization account recovery data is null"); + } + + const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData( + originalUserKey, + newUnencryptedUserKey, + user.id, + ); + const unlockDataRequest = new UnlockDataRequest( + masterPasswordUnlockData, + emergencyAccessUnlockData, + organizationAccountRecoveryUnlockData, + passkeyUnlockData, + ); + + const request = new RotateUserAccountKeysRequest( + unlockDataRequest, + accountKeysRequest, + accountDataRequest, + await this.keyService.hashMasterKey(oldMasterPassword, oldMasterKey), + ); + + this.logService.info("[Userkey rotation] Posting user key rotation request to server"); + await this.apiService.postUserKeyUpdateV2(request); + this.logService.info("[Userkey rotation] Userkey rotation request posted to server"); + + // TODO PM-2199: Add device trust rotation support to the user key rotation endpoint + this.logService.info("[Userkey rotation] Rotating device trust..."); + await this.deviceTrustService.rotateDevicesTrust( + user.id, + newUnencryptedUserKey, + newMasterKeyAuthenticationHash, + ); + this.logService.info("[Userkey rotation] Device trust rotation completed"); + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("rotationCompletedTitle"), + message: this.i18nService.t("rotationCompletedDesc"), + timeout: 15000, + }); + + // temporary until userkey can be better verified + await this.vaultTimeoutService.logOut(); + } + + /** + * Creates a new user key and re-encrypts all required data with the it. + * @param masterPassword current master password (used for validation) + * @deprecated + */ + async rotateUserKeyAndEncryptedDataLegacy(masterPassword: string, user: Account): Promise { + this.logService.info("[Userkey rotation] Starting legacy user key rotation..."); if (!masterPassword) { this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!"); throw new Error("Invalid master password"); @@ -168,6 +344,7 @@ export class UserKeyRotationService { this.logService.info("[Userkey rotation] Rotating device trust..."); await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash); this.logService.info("[Userkey rotation] Device trust rotation completed"); + await this.vaultTimeoutService.logOut(); } private async encryptPrivateKey( diff --git a/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts index 72ceb2afe7d..62456d96401 100644 --- a/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts +++ b/apps/web/src/app/key-management/migrate-encryption/migrate-legacy-encryption.component.ts @@ -64,7 +64,7 @@ export class MigrateFromLegacyEncryptionComponent { try { await this.syncService.fullSync(false, true); - await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser); + await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(masterPassword, activeUser); this.toastService.showToast({ variant: "success", diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts b/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts index 1b6199b771e..d56dd6dda3b 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.spec.ts @@ -224,6 +224,8 @@ describe("UserVerificationService", () => { expect(result).toEqual({ policyOptions: null, masterKey: "masterKey", + kdfConfig: "kdfConfig", + email: "email", }); }); @@ -282,6 +284,8 @@ describe("UserVerificationService", () => { expect(result).toEqual({ policyOptions: "MasterPasswordPolicyOptions", masterKey: "masterKey", + kdfConfig: "kdfConfig", + email: "email", }); }); diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 8f171c6064e..1ff629114ab 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -237,7 +237,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti ); await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); await this.masterPasswordService.setMasterKey(masterKey, userId); - return { policyOptions, masterKey }; + return { policyOptions, masterKey, kdfConfig, email }; } private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise { diff --git a/libs/common/src/auth/types/verification.ts b/libs/common/src/auth/types/verification.ts index f3df2c3ad1e..307a584fb36 100644 --- a/libs/common/src/auth/types/verification.ts +++ b/libs/common/src/auth/types/verification.ts @@ -1,3 +1,5 @@ +import { KdfConfig } from "@bitwarden/key-management"; + import { MasterKey } from "../../types/key"; import { VerificationType } from "../enums/verification-type"; import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response"; @@ -22,5 +24,7 @@ export type ServerSideVerification = OtpVerification | MasterPasswordVerificatio export type MasterPasswordVerificationResponse = { masterKey: MasterKey; + kdfConfig: KdfConfig; + email: string; policyOptions: MasterPasswordPolicyResponse | null; }; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d35a1854653..5e52eb31840 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -39,6 +39,7 @@ export enum FeatureFlag { /* Auth */ PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence", + UserKeyRotationV2 = "userkey-rotation-v2", PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", CipherKeyEncryption = "cipher-key-encryption", @@ -99,6 +100,7 @@ export const DefaultFeatureFlagValue = { /* Auth */ [FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE, + [FeatureFlag.UserKeyRotationV2]: FALSE, [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 8cc80e38435..659dd1bbb29 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -326,6 +326,17 @@ export abstract class KeyService { */ abstract userPrivateKeyWithLegacySupport$(userId: UserId): Observable; + /** + * Gets an observable stream of the given users decrypted private key and public key, guaranteed to be consistent. + * Will emit null if the user doesn't have a userkey to decrypt the encrypted private key, or null if the user doesn't have a private key + * at all. + * + * @param userId The user id of the user to get the data for. + */ + abstract userEncryptionKeyPair$( + userId: UserId, + ): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null>; + /** * Generates a fingerprint phrase for the user based on their public key * diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 8c201152997..2eab9de7487 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { bufferCount, firstValueFrom, lastValueFrom, of, take, tap } from "rxjs"; +import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take, tap } from "rxjs"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; @@ -802,4 +802,51 @@ describe("keyService", () => { }, ); }); + + describe("userPrivateKey$", () => { + type SetupKeysParams = { + makeMasterKey: boolean; + makeUserKey: boolean; + }; + + function setupKeys({ makeMasterKey, makeUserKey }: SetupKeysParams): [UserKey, MasterKey] { + const userKeyState = stateProvider.singleUser.getFake(mockUserId, USER_KEY); + const fakeMasterKey = makeMasterKey ? makeSymmetricCryptoKey(64) : null; + masterPasswordService.masterKeySubject.next(fakeMasterKey); + userKeyState.nextState(null); + const fakeUserKey = makeUserKey ? makeSymmetricCryptoKey(64) : null; + userKeyState.nextState(fakeUserKey); + return [fakeUserKey, fakeMasterKey]; + } + + it("returns null when private key is null", async () => { + setupKeys({ makeMasterKey: false, makeUserKey: false }); + + keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject(null)); + const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId)); + expect(key).toEqual(null); + }); + + it("returns null when private key is undefined", async () => { + setupKeys({ makeUserKey: true, makeMasterKey: false }); + + keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject(undefined)); + const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId)); + expect(key).toEqual(null); + }); + + it("returns keys when private key is defined", async () => { + setupKeys({ makeUserKey: false, makeMasterKey: true }); + + keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key")); + cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + Utils.fromUtf8ToArray("public key"), + ); + const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId)); + expect(key).toEqual({ + privateKey: "private key", + publicKey: Utils.fromUtf8ToArray("public key"), + }); + }); + }); }); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index 0de6d4859d2..b18405a4200 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -895,6 +895,21 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } + userEncryptionKeyPair$( + userId: UserId, + ): Observable<{ privateKey: UserPrivateKey; publicKey: UserPublicKey } | null> { + return this.userPrivateKey$(userId).pipe( + switchMap(async (privateKey) => { + if (privateKey == null) { + return null; + } + + const publicKey = (await this.derivePublicKey(privateKey))!; + return { privateKey, publicKey }; + }), + ); + } + userEncryptedPrivateKey$(userId: UserId): Observable { return this.stateProvider.getUser(userId, USER_ENCRYPTED_PRIVATE_KEY).state$; } From c8069baa2403a2fa8d60d530cff9a52c2be9a8cd Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Mon, 24 Mar 2025 16:26:40 -0400 Subject: [PATCH 004/228] fix typo (#13948) --- .../src/app/billing/individual/premium/premium.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/individual/premium/premium.component.html b/apps/web/src/app/billing/individual/premium/premium.component.html index 6a212ef9d7c..3f0f97541df 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.html +++ b/apps/web/src/app/billing/individual/premium/premium.component.html @@ -55,7 +55,7 @@

Date: Tue, 25 Mar 2025 07:41:35 -0400 Subject: [PATCH 005/228] Migrated Stripe payment fields from Bootstrap to Tailwind (#13633) --- .../app/billing/services/stripe.service.ts | 2 +- .../shared/payment/payment.component.html | 6 +-- apps/web/src/scss/forms.scss | 52 ------------------- apps/web/src/scss/tailwind.css | 23 ++++++++ 4 files changed, 27 insertions(+), 56 deletions(-) diff --git a/apps/web/src/app/billing/services/stripe.service.ts b/apps/web/src/app/billing/services/stripe.service.ts index aac86107e26..5c35923c1f4 100644 --- a/apps/web/src/app/billing/services/stripe.service.ts +++ b/apps/web/src/app/billing/services/stripe.service.ts @@ -161,6 +161,7 @@ export class StripeService { }, }, classes: { + base: "tw-stripe-form-control", focus: "is-focused", empty: "is-empty", invalid: "is-invalid", @@ -168,7 +169,6 @@ export class StripeService { }; options.style.base.fontWeight = "500"; - options.classes.base = "v2"; // Remove the placeholder for number and CVC fields if (["cardNumber", "cardCvc"].includes(element)) { diff --git a/apps/web/src/app/billing/shared/payment/payment.component.html b/apps/web/src/app/billing/shared/payment/payment.component.html index af261155171..34b2c54d8b5 100644 --- a/apps/web/src/app/billing/shared/payment/payment.component.html +++ b/apps/web/src/app/billing/shared/payment/payment.component.html @@ -46,7 +46,7 @@ {{ "number" | i18n }} -
+
{{ "expiration" | i18n }} -
+
diff --git a/apps/web/src/scss/forms.scss b/apps/web/src/scss/forms.scss index 330bc5f6547..fd40d977911 100644 --- a/apps/web/src/scss/forms.scss +++ b/apps/web/src/scss/forms.scss @@ -98,58 +98,6 @@ input[type="checkbox"] { cursor: pointer; } -.form-control.stripe-form-control:not(.v2) { - padding-top: 0.55rem; - - &.is-focused { - outline: 0; - @include themify($themes) { - background-color: themed("inputBackgroundColor"); - border-color: themed("inputBorderColor"); - box-shadow: 0 0 0 $input-focus-width - rgba(mix(color-yiq(themed("primary")), themed("primary"), 15%), 0.5); - color: themed("inputTextColor"); - } - - &.is-invalid { - opacity: 0.75; - @include themify($themes) { - box-shadow: 0 0 0 $input-focus-width themed("danger"); - } - } - } - - &.is-invalid { - @include themify($themes) { - border-color: themed("danger"); - } - } -} - -.form-control.stripe-form-control.v2 { - padding: 0.6875rem 0.875rem; - border-radius: 0.5rem; - border-color: rgb(var(--color-text-muted)); - height: unset; - font-weight: 500; - color: rgb(var(--color-text-main)); - background-color: rgb(var(--color-background)); - - &:hover { - border-color: rgb(var(--color-primary-600)); - } - - &.is-focused { - outline: 0; - border-color: rgb(var(--color-primary-600)); - } - - &.is-invalid { - color: rgb(var(--color-text-main)); - border-color: rgb(var(--color-danger-600)); - } -} - .dropdown-menu, .dropdown-item { @include themify($themes) { diff --git a/apps/web/src/scss/tailwind.css b/apps/web/src/scss/tailwind.css index 0ae3c291b56..d0878ac080b 100644 --- a/apps/web/src/scss/tailwind.css +++ b/apps/web/src/scss/tailwind.css @@ -29,6 +29,29 @@ @apply tw-text-muted !important; } + /** + * Stripe form control styling + */ + .tw-stripe-form-control { + @apply tw-block tw-w-full tw-h-11 tw-rounded-lg tw-bg-background tw-border tw-border-solid tw-border-secondary-500 tw-px-3 tw-py-2 tw-text-main; + + @apply hover:tw-border-primary-600; + + @apply focus:tw-outline-none focus:tw-border-primary-600 focus:tw-border-2 focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-600; + + &.is-invalid { + @apply tw-border-danger-600 hover:tw-border-danger-700; + } + + &.is-focused { + @apply tw-outline-none tw-border-primary-600 focus:tw-border-primary-600; + + &.is-invalid { + @apply tw-border-danger-600; + } + } + } + /** * Loading page */ From 034112f42ee141a84c93033fadced3893021cd27 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 25 Mar 2025 08:11:32 -0400 Subject: [PATCH 006/228] Hide bank account for provider when initial tax info is non-US (#13968) --- .../adjust-payment-dialog.component.html | 2 +- .../adjust-payment-dialog.component.ts | 27 ++++++++++++++++--- .../manage-tax-information.component.ts | 24 ++++++++--------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html index 4409ab56d60..9c70908af8e 100644 --- a/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html +++ b/apps/web/src/app/billing/shared/adjust-payment-dialog/adjust-payment-dialog.component.html @@ -1,4 +1,4 @@ - + { this.taxInformation = TaxInformation.from(response); + this.toggleBankAccount(); }) .catch(() => { this.taxInformation = new TaxInformation(); + }) + .finally(() => { + this.loading = false; }); } else if (this.providerId) { this.billingApiService .getProviderTaxInformation(this.providerId) - .then((response) => (this.taxInformation = TaxInformation.from(response))) + .then((response) => { + this.taxInformation = TaxInformation.from(response); + this.toggleBankAccount(); + }) .catch(() => { this.taxInformation = new TaxInformation(); + }) + .finally(() => { + this.loading = false; }); } else { this.apiService @@ -91,21 +103,28 @@ export class AdjustPaymentDialogComponent implements OnInit { }) .catch(() => { this.taxInformation = new TaxInformation(); + }) + .finally(() => { + this.loading = false; }); } } taxInformationChanged(event: TaxInformation) { this.taxInformation = event; - if (event.country === "US") { - this.paymentComponent.showBankAccount = !!this.organizationId; + this.toggleBankAccount(); + } + + toggleBankAccount = () => { + if (this.taxInformation.country === "US") { + this.paymentComponent.showBankAccount = !!this.organizationId || !!this.providerId; } else { this.paymentComponent.showBankAccount = false; if (this.paymentComponent.selected === PaymentMethodType.BankAccount) { this.paymentComponent.select(PaymentMethodType.Card); } } - } + }; submit = async (): Promise => { if (!this.taxInfoComponent.validate()) { diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts index 885afb1ae67..e966b4e0a75 100644 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts @@ -77,6 +77,18 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy { } async ngOnInit() { + this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => { + this.taxInformation = { + country: values.country, + postalCode: values.postalCode, + taxId: values.taxId, + line1: values.line1, + line2: values.line2, + city: values.city, + state: values.state, + }; + }); + if (this.startWith) { this.formGroup.controls.country.setValue(this.startWith.country); this.formGroup.controls.postalCode.setValue(this.startWith.postalCode); @@ -95,18 +107,6 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy { } } - this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => { - this.taxInformation = { - country: values.country, - postalCode: values.postalCode, - taxId: values.taxId, - line1: values.line1, - line2: values.line2, - city: values.city, - state: values.state, - }; - }); - this.formGroup.controls.country.valueChanges .pipe(debounceTime(1000), takeUntil(this.destroy$)) .subscribe((country: string) => { From 27baa92fcfee619eaca65b148ef1f5b97c9b844c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 25 Mar 2025 13:30:54 +0100 Subject: [PATCH 007/228] [PM-10749] [BEEEP] New export format: Zip with attachments (#10465) * Add new export format: zip * Restrict zip export to just individual vaults * Add tests * Remove unused import * Fix build error * Fix tests * Fix test * Fix retrieval of ciphers by passing in activeUserId * Guard feature behind `export-attachments`-feature-flag * Extend cipher filter to also filter out any ciphers that are assigned to an organization * Added apiService to retrieve AttachmentData (metaData) and then download the attachment - Added ApiService as a depdency within DI for VaultExportService/IndividualVaultExportService - Added unit tests for filtering ciphers - Added unit test for downloading attachment metadata and attachments * Moved attachment decryption into a separate method and added unit tests * Added null check for creating the base attachment folder * Move format check for zip within Org export into an early return/throw * Add feature flag guard on the CLI * Extend ExportScopeCallout to display an individual export will contain attachment when zip-format is selected * Fix adding/removing the zip-export option based on selected vault and state of `export-attachments` feature-flag * Separate AAA visually using whitespace within tests * Remove unused error var * Write test that verifies different http request failures when retrieving attachment data * Remove uneeded ignore lint rule * Rewrite test to actually check that ciphers assigned to an org are filtered out * Introduce ExportedVault return type (#13842) * Define ExportedVault type unioned by 2 new types that describe a plain-text export vs a blob-based zip-export * Extend static getFileName to handle formats and add unit-tests * Introduce new export return type throughout the vault export module - Update abstractions - Update return types within implementations - Update callers/consumers to handle the new return value - Fix all unit tests * Add support for new export return type and fix download of blobs via CLI * Add documentation to public methods --------- Co-authored-by: Daniel James Smith --------- Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Co-authored-by: Daniel James Smith --- apps/browser/src/_locales/en/messages.json | 9 + .../browser/src/background/main.background.ts | 1 + .../service-container/service-container.ts | 1 + apps/cli/src/tools/export.command.ts | 48 +++-- apps/cli/src/vault.program.ts | 1 + apps/desktop/src/locales/en/messages.json | 9 + apps/web/src/locales/en/messages.json | 9 + .../src/services/jslib-services.module.ts | 1 + .../src/services/export-helper.spec.ts | 30 +++ .../src/services/export-helper.ts | 15 +- ...vidual-vault-export.service.abstraction.ts | 8 +- .../individual-vault-export.service.spec.ts | 194 ++++++++++++++++-- .../individual-vault-export.service.ts | 133 +++++++++++- .../org-vault-export.service.abstraction.ts | 12 +- .../src/services/org-vault-export.service.ts | 56 ++++- .../vault-export.service.abstraction.ts | 13 +- .../src/services/vault-export.service.spec.ts | 59 ++++-- .../src/services/vault-export.service.ts | 31 ++- .../src/types/exported-vault-type.ts | 13 ++ .../vault-export-core/src/types/index.ts | 1 + .../export-scope-callout.component.ts | 43 ++-- .../src/components/export.component.html | 5 +- .../src/components/export.component.ts | 56 ++--- 23 files changed, 592 insertions(+), 156 deletions(-) create mode 100644 libs/tools/export/vault-export/vault-export-core/src/services/export-helper.spec.ts create mode 100644 libs/tools/export/vault-export/vault-export-core/src/types/exported-vault-type.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 16f462b7f17..39541aa2f8c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 74fa6acdf79..f37f0a3c440 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1026,6 +1026,7 @@ export default class MainBackground { this.cryptoFunctionService, this.kdfConfigService, this.accountService, + this.apiService, ); this.organizationVaultExportService = new OrganizationVaultExportService( diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index b7f423e8ff7..c82a744b4a1 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -795,6 +795,7 @@ export class ServiceContainer { this.cryptoFunctionService, this.kdfConfigService, this.accountService, + this.apiService, ); this.organizationExportService = new OrganizationVaultExportService( diff --git a/apps/cli/src/tools/export.command.ts b/apps/cli/src/tools/export.command.ts index b2d9d0151a9..df6e7bae1cb 100644 --- a/apps/cli/src/tools/export.command.ts +++ b/apps/cli/src/tools/export.command.ts @@ -7,11 +7,15 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { EventType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ExportFormat, EXPORT_FORMATS, VaultExportServiceAbstraction, + ExportedVault, + ExportedVaultAsBlob, } from "@bitwarden/vault-export-core"; import { Response } from "../models/response"; @@ -22,6 +26,7 @@ export class ExportCommand { private exportService: VaultExportServiceAbstraction, private policyService: PolicyService, private eventCollectionService: EventCollectionService, + private configService: ConfigService, ) {} async run(options: OptionValues): Promise { @@ -42,6 +47,13 @@ export class ExportCommand { const format = password && options.format == "json" ? "encrypted_json" : (options.format ?? "csv"); + if ( + format == "zip" && + !(await this.configService.getFeatureFlag(FeatureFlag.ExportAttachments)) + ) { + return Response.badRequest("Exporting attachments is not supported in this environment."); + } + if (!this.isSupportedExportFormat(format)) { return Response.badRequest( `'${format}' is not a supported export format. Supported formats: ${EXPORT_FORMATS.join( @@ -54,7 +66,7 @@ export class ExportCommand { return Response.error("`" + options.organizationid + "` is not a GUID."); } - let exportContent: string = null; + let exportContent: ExportedVault = null; try { if (format === "encrypted_json") { password = await this.promptPassword(password); @@ -78,34 +90,28 @@ export class ExportCommand { } catch (e) { return Response.error(e); } - return await this.saveFile(exportContent, options, format); + return await this.saveFile(exportContent, options); } - private async saveFile( - exportContent: string, - options: OptionValues, - format: ExportFormat, - ): Promise { + private async saveFile(exportContent: ExportedVault, options: OptionValues): Promise { try { - const fileName = this.getFileName(format, options.organizationid != null ? "org" : null); - return await CliUtils.saveResultToFile(exportContent, options.output, fileName); + if (exportContent.type === "application/zip") { + exportContent = exportContent as ExportedVaultAsBlob; + const arrayBuffer = await exportContent.data.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + return await CliUtils.saveResultToFile(buffer, options.output, exportContent.fileName); + } + + return await CliUtils.saveResultToFile( + exportContent.data, + options.output, + exportContent.fileName, + ); } catch (e) { return Response.error(e.toString()); } } - private getFileName(format: ExportFormat, prefix?: string) { - if (format === "encrypted_json") { - if (prefix == null) { - prefix = "encrypted"; - } else { - prefix = "encrypted_" + prefix; - } - format = "json"; - } - return this.exportService.getFileName(prefix, format); - } - private async promptPassword(password: string | boolean) { // boolean => flag set with no value, we need to prompt for password // string => flag set with value, use this value for password diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index d6ef27b1428..8b9d441f0ff 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -501,6 +501,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.exportService, this.serviceContainer.policyService, this.serviceContainer.eventCollectionService, + this.serviceContainer.configService, ); const response = await command.run(options); this.processResponse(response); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f93db44aa69..07404b37fcd 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 7f9c89484c2..b9ead1df44d 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6762,6 +6762,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 9cb39a35856..4672788eb81 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -846,6 +846,7 @@ const safeProviders: SafeProvider[] = [ CryptoFunctionServiceAbstraction, KdfConfigService, AccountServiceAbstraction, + ApiServiceAbstraction, ], }), safeProvider({ diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.spec.ts new file mode 100644 index 00000000000..913f6cdf56e --- /dev/null +++ b/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.spec.ts @@ -0,0 +1,30 @@ +import { ExportHelper } from "./export-helper"; + +describe("ExportHelper", () => { + describe("getFileName", () => { + it("should generate a filename with default prefix and format", () => { + const fileName = ExportHelper.getFileName(); + expect(fileName).toMatch(/^bitwarden_export_\d{8}\d{6}\.csv$/); + }); + + it("should generate a filename with given prefix and default format", () => { + const fileName = ExportHelper.getFileName("test"); + expect(fileName).toMatch(/^bitwarden_test_export_\d{8}\d{6}\.csv$/); + }); + + it("should generate a filename with given prefix and given format", () => { + const fileName = ExportHelper.getFileName("org", "json"); + expect(fileName).toMatch(/^bitwarden_org_export_\d{8}\d{6}\.json$/); + }); + + it("should generate a filename with encrypted_json format and modify prefix", () => { + const fileName = ExportHelper.getFileName("org", "encrypted_json"); + expect(fileName).toMatch(/^bitwarden_encrypted_org_export_\d{8}\d{6}\.json$/); + }); + + it("should generate a filename with encrypted_json format and default prefix", () => { + const fileName = ExportHelper.getFileName("", "encrypted_json"); + expect(fileName).toMatch(/^bitwarden_encrypted_export_\d{8}\d{6}\.json$/); + }); + }); +}); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.ts b/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.ts index bb38197a4ac..fa5ff2ca0ff 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/export-helper.ts @@ -1,7 +1,14 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore export class ExportHelper { - static getFileName(prefix: string = null, extension = "csv"): string { + static getFileName(prefix: string = "", format = "csv"): string { + if (format === "encrypted_json") { + if (prefix == "") { + prefix = "encrypted"; + } else { + prefix = "encrypted_" + prefix; + } + format = "json"; + } + const now = new Date(); const dateString = now.getFullYear() + @@ -14,7 +21,7 @@ export class ExportHelper { this.padNumber(now.getMinutes(), 2) + this.padNumber(now.getSeconds(), 2); - return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension; + return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + format; } private static padNumber(num: number, width: number, padCharacter = "0"): string { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.abstraction.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.abstraction.ts index b075f605fd8..a2adae8aa91 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.abstraction.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.abstraction.ts @@ -1,8 +1,8 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore +import { ExportedVault } from "../types"; + import { ExportFormat } from "./vault-export.service.abstraction"; export abstract class IndividualVaultExportServiceAbstraction { - getExport: (format: ExportFormat) => Promise; - getPasswordProtectedExport: (password: string) => Promise; + abstract getExport: (format: ExportFormat) => Promise; + abstract getPasswordProtectedExport: (password: string) => Promise; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 32f441235b9..04d32b843bf 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -1,7 +1,9 @@ import { mock, MockProxy } from "jest-mock-extended"; +import * as JSZip from "jszip"; import { BehaviorSubject, of } from "rxjs"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; @@ -12,9 +14,14 @@ import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { AttachmentData } from "@bitwarden/common/vault/models/data/attachment.data"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { Attachment } from "@bitwarden/common/vault/models/domain/attachment"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Folder } from "@bitwarden/common/vault/models/domain/folder"; import { Login } from "@bitwarden/common/vault/models/domain/login"; +import { AttachmentResponse } from "@bitwarden/common/vault/models/response/attachment.response"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; @@ -27,6 +34,12 @@ import { } from "@bitwarden/key-management"; import { BuildTestObject, GetUniqueString } from "../../../../../../common/spec"; +import { + BitwardenJsonExport, + ExportedVault, + ExportedVaultAsBlob, + ExportedVaultAsString, +} from "../types"; import { IndividualVaultExportService } from "./individual-vault-export.service"; @@ -156,6 +169,8 @@ describe("VaultExportService", () => { let encryptService: MockProxy; let kdfConfigService: MockProxy; let accountService: MockProxy; + let apiService: MockProxy; + let fetchMock: jest.Mock; beforeEach(() => { cryptoFunctionService = mock(); @@ -166,6 +181,7 @@ describe("VaultExportService", () => { encryptService = mock(); kdfConfigService = mock(); accountService = mock(); + apiService = mock(); keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any)); @@ -178,10 +194,23 @@ describe("VaultExportService", () => { const activeAccount = { id: userId, ...accountInfo }; accountService.activeAccount$ = new BehaviorSubject(activeAccount); + fetchMock = jest.fn().mockResolvedValue({}); + global.fetch = fetchMock; + + const attachmentResponse = { + id: GetUniqueString("id"), + url: "https://someurl.com", + fileName: "fileName", + key: GetUniqueString("key"), + size: "size", + sizeName: "sizeName", + } as AttachmentResponse; + folderService.folderViews$.mockReturnValue(of(UserFolderViews)); folderService.folders$.mockReturnValue(of(UserFolders)); kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG); encryptService.encrypt.mockResolvedValue(new EncString("encrypted")); + apiService.getAttachmentData.mockResolvedValue(attachmentResponse); exportService = new IndividualVaultExportService( folderService, @@ -192,6 +221,7 @@ describe("VaultExportService", () => { cryptoFunctionService, kdfConfigService, accountService, + apiService, ); }); @@ -199,35 +229,160 @@ describe("VaultExportService", () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); const actual = await exportService.getExport("json"); - - expectEqualCiphers(UserCipherViews.slice(0, 1), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherViews.slice(0, 1), exportedData.data); }); it("exports encrypted json user ciphers", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1)); const actual = await exportService.getExport("encrypted_json"); - - expectEqualCiphers(UserCipherDomains.slice(0, 1), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherDomains.slice(0, 1), exportedData.data); }); it("does not unencrypted export trashed user items", async () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews); const actual = await exportService.getExport("json"); - - expectEqualCiphers(UserCipherViews.slice(0, 2), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherViews.slice(0, 2), exportedData.data); }); it("does not encrypted export trashed user items", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains); const actual = await exportService.getExport("encrypted_json"); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherDomains.slice(0, 2), exportedData.data); + }); - expectEqualCiphers(UserCipherDomains.slice(0, 2), actual); + describe("zip export", () => { + it("contains data.json", async () => { + cipherService.getAllDecrypted.mockResolvedValue([]); + folderService.getAllDecryptedFromState.mockResolvedValue([]); + + const exportedVault = await exportService.getExport("zip"); + + expect(exportedVault.type).toBe("application/zip"); + const exportZip = exportedVault as ExportedVaultAsBlob; + const zip = await JSZip.loadAsync(exportZip.data); + const data = await zip.file("data.json")?.async("string"); + expect(data).toBeDefined(); + }); + + it("filters out ciphers that are assigned to an org", async () => { + // Create a cipher that is not assigned to an org + const cipherData = new CipherData(); + cipherData.id = "mock-id"; + const cipherView = new CipherView(new Cipher(cipherData)); + + // Create a cipher that is assigned to an org + const orgCipher = new CipherData(); + orgCipher.id = "mock-from-org-id"; + orgCipher.organizationId = "mock-org-id"; + const orgCipherView = new CipherView(new Cipher(orgCipher)); + + // Mock the cipher service to return both ciphers + cipherService.getAllDecrypted.mockResolvedValue([cipherView, orgCipherView]); + folderService.getAllDecryptedFromState.mockResolvedValue([]); + + const exportedVault = await exportService.getExport("zip"); + + const zip = await JSZip.loadAsync(exportedVault.data); + const data = await zip.file("data.json")?.async("string"); + const exportData: BitwardenJsonExport = JSON.parse(data); + expect(exportData.items.length).toBe(1); + expect(exportData.items[0].id).toBe("mock-id"); + expect(exportData.items[0].organizationId).toBe(null); + }); + + it.each([[400], [401], [404], [500]])( + "throws error if the http request fails (status === %n)", + async (status) => { + const cipherData = new CipherData(); + cipherData.id = "mock-id"; + const cipherView = new CipherView(new Cipher(cipherData)); + const attachmentView = new AttachmentView(new Attachment(new AttachmentData())); + attachmentView.fileName = "mock-file-name"; + cipherView.attachments = [attachmentView]; + + cipherService.getAllDecrypted.mockResolvedValue([cipherView]); + folderService.getAllDecryptedFromState.mockResolvedValue([]); + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255)); + + global.fetch = jest.fn(() => + Promise.resolve({ + status, + }), + ) as any; + global.Request = jest.fn(() => {}) as any; + + await expect(async () => { + await exportService.getExport("zip"); + }).rejects.toThrow("Error downloading attachment"); + }, + ); + + it("throws error if decrypting attachment fails", async () => { + const cipherData = new CipherData(); + cipherData.id = "mock-id"; + const cipherView = new CipherView(new Cipher(cipherData)); + const attachmentView = new AttachmentView(new Attachment(new AttachmentData())); + attachmentView.fileName = "mock-file-name"; + cipherView.attachments = [attachmentView]; + + cipherService.getAllDecrypted.mockResolvedValue([cipherView]); + folderService.getAllDecryptedFromState.mockResolvedValue([]); + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255)); + + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + arrayBuffer: () => Promise.resolve(null), + }), + ) as any; + global.Request = jest.fn(() => {}) as any; + + await expect(async () => { + await exportService.getExport("zip"); + }).rejects.toThrow("Error decrypting attachment"); + }); + + it("contains attachments with folders", async () => { + const cipherData = new CipherData(); + cipherData.id = "mock-id"; + const cipherView = new CipherView(new Cipher(cipherData)); + const attachmentView = new AttachmentView(new Attachment(new AttachmentData())); + attachmentView.fileName = "mock-file-name"; + cipherView.attachments = [attachmentView]; + cipherService.getAllDecrypted.mockResolvedValue([cipherView]); + folderService.getAllDecryptedFromState.mockResolvedValue([]); + encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255)); + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(255)), + }), + ) as any; + global.Request = jest.fn(() => {}) as any; + + const exportedVault = await exportService.getExport("zip"); + + expect(exportedVault.type).toBe("application/zip"); + const exportZip = exportedVault as ExportedVaultAsBlob; + const zip = await JSZip.loadAsync(exportZip.data); + const attachment = await zip.file("attachments/mock-id/mock-file-name")?.async("blob"); + expect(attachment).toBeDefined(); + }); }); describe("password protected export", () => { + let exportedVault: ExportedVault; let exportString: string; let exportObject: any; let mac: MockProxy; @@ -246,7 +401,8 @@ describe("VaultExportService", () => { jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(salt); cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); - exportString = await exportService.getPasswordProtectedExport(password); + exportedVault = await exportService.getPasswordProtectedExport(password); + exportString = exportedVault.data; exportObject = JSON.parse(exportString); }); @@ -272,7 +428,8 @@ describe("VaultExportService", () => { it("has a mac property", async () => { encryptService.encrypt.mockResolvedValue(mac); - exportString = await exportService.getPasswordProtectedExport(password); + exportedVault = await exportService.getPasswordProtectedExport(password); + exportString = exportedVault.data; exportObject = JSON.parse(exportString); expect(exportObject.encKeyValidation_DO_NOT_EDIT).toEqual(mac.encryptedString); @@ -280,15 +437,18 @@ describe("VaultExportService", () => { it("has data property", async () => { encryptService.encrypt.mockResolvedValue(data); - exportString = await exportService.getPasswordProtectedExport(password); + exportedVault = await exportService.getPasswordProtectedExport(password); + exportString = exportedVault.data; exportObject = JSON.parse(exportString); expect(exportObject.data).toEqual(data.encryptedString); }); it("encrypts the data property", async () => { - const unencrypted = await exportService.getExport(); - expect(exportObject.data).not.toEqual(unencrypted); + const unEncryptedExportVault = await exportService.getExport(); + + const unEncryptedExportString = unEncryptedExportVault.data; + expect(exportObject.data).not.toEqual(unEncryptedExportString); }); }); }); @@ -296,17 +456,23 @@ describe("VaultExportService", () => { it("exported unencrypted object contains folders", async () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); folderService.folderViews$.mockReturnValue(of(UserFolderViews)); + const actual = await exportService.getExport("json"); - expectEqualFolderViews(UserFolderViews, actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualFolderViews(UserFolderViews, exportedData.data); }); it("exported encrypted json contains folders", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1)); folderService.folders$.mockReturnValue(of(UserFolders)); + const actual = await exportService.getExport("encrypted_json"); - expectEqualFolders(UserFolders, actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualFolders(UserFolders, exportedData.data); }); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 1718bd54234..0fc1f336b90 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -1,20 +1,24 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import * as JSZip from "jszip"; import * as papa from "papaparse"; import { firstValueFrom } from "rxjs"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KdfConfigService, KeyService } from "@bitwarden/key-management"; @@ -23,9 +27,13 @@ import { BitwardenCsvIndividualExportType, BitwardenEncryptedIndividualJsonExport, BitwardenUnEncryptedIndividualJsonExport, + ExportedVault, + ExportedVaultAsBlob, + ExportedVaultAsString, } from "../types"; import { BaseVaultExportService } from "./base-vault-export.service"; +import { ExportHelper } from "./export-helper"; import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction"; import { ExportFormat } from "./vault-export.service.abstraction"; @@ -42,23 +50,118 @@ export class IndividualVaultExportService cryptoFunctionService: CryptoFunctionService, kdfConfigService: KdfConfigService, private accountService: AccountService, + private apiService: ApiService, ) { super(pinService, encryptService, cryptoFunctionService, kdfConfigService); } - async getExport(format: ExportFormat = "csv"): Promise { + /** Creates an export of an individual vault (My Vault). Based on the provided format it will either be unencrypted, encrypted or password protected and in case zip is selected will include attachments + * @param format The format of the export + */ + async getExport(format: ExportFormat = "csv"): Promise { if (format === "encrypted_json") { return this.getEncryptedExport(); + } else if (format === "zip") { + return this.getDecryptedExportZip(); } return this.getDecryptedExport(format); } - async getPasswordProtectedExport(password: string): Promise { - const clearText = await this.getExport("json"); - return this.buildPasswordExport(clearText, password); + /** Creates a password protected export of an individiual vault (My Vault) as a JSON file + * @param password The password to encrypt the export with + * @returns A password-protected encrypted individual vault export + */ + async getPasswordProtectedExport(password: string): Promise { + const exportVault = await this.getExport("json"); + + if (exportVault.type !== "text/plain") { + throw new Error("Unexpected export type"); + } + + return { + type: "text/plain", + data: await this.buildPasswordExport(exportVault.data, password), + fileName: ExportHelper.getFileName("json"), + } as ExportedVaultAsString; } - private async getDecryptedExport(format: "json" | "csv"): Promise { + /** Creates a unencrypted export of an individual vault including attachments + * @returns A unencrypted export including attachments + */ + async getDecryptedExportZip(): Promise { + const zip = new JSZip(); + + // ciphers + const exportedVault = await this.getDecryptedExport("json"); + zip.file("data.json", exportedVault.data); + + const attachmentsFolder = zip.folder("attachments"); + if (attachmentsFolder == null) { + throw new Error("Error creating attachments folder"); + } + + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + // attachments + for (const cipher of await this.cipherService.getAllDecrypted(activeUserId)) { + if ( + !cipher.attachments || + cipher.attachments.length === 0 || + cipher.deletedDate != null || + cipher.organizationId != null + ) { + continue; + } + + const cipherFolder = attachmentsFolder.folder(cipher.id); + for (const attachment of cipher.attachments) { + const response = await this.downloadAttachment(cipher.id, attachment.id); + const decBuf = await this.decryptAttachment(cipher, attachment, response); + cipherFolder.file(attachment.fileName, decBuf); + } + } + + const blobData = await zip.generateAsync({ type: "blob" }); + + return { + type: "application/zip", + data: blobData, + fileName: ExportHelper.getFileName("json"), + } as ExportedVaultAsBlob; + } + + private async downloadAttachment(cipherId: string, attachmentId: string): Promise { + const attachmentDownloadResponse = await this.apiService.getAttachmentData( + cipherId, + attachmentId, + ); + const url = attachmentDownloadResponse.url; + + const response = await fetch(new Request(url, { cache: "no-store" })); + if (response.status !== 200) { + throw new Error("Error downloading attachment"); + } + return response; + } + + private async decryptAttachment( + cipher: CipherView, + attachment: AttachmentView, + response: Response, + ) { + try { + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = + attachment.key != null + ? attachment.key + : await this.keyService.getOrgKey(cipher.organizationId); + return await this.encryptService.decryptToBytes(encBuf, key); + } catch { + throw new Error("Error decrypting attachment"); + } + } + + private async getDecryptedExport(format: "json" | "csv"): Promise { let decFolders: FolderView[] = []; let decCiphers: CipherView[] = []; const promises = []; @@ -79,13 +182,21 @@ export class IndividualVaultExportService await Promise.all(promises); if (format === "csv") { - return this.buildCsvExport(decFolders, decCiphers); + return { + type: "text/plain", + data: this.buildCsvExport(decFolders, decCiphers), + fileName: ExportHelper.getFileName("csv"), + } as ExportedVaultAsString; } - return this.buildJsonExport(decFolders, decCiphers); + return { + type: "text/plain", + data: this.buildJsonExport(decFolders, decCiphers), + fileName: ExportHelper.getFileName("json"), + } as ExportedVaultAsString; } - private async getEncryptedExport(): Promise { + private async getEncryptedExport(): Promise { let folders: Folder[] = []; let ciphers: Cipher[] = []; const promises = []; @@ -136,7 +247,11 @@ export class IndividualVaultExportService jsonDoc.items.push(cipher); }); - return JSON.stringify(jsonDoc, null, " "); + return { + type: "text/plain", + data: JSON.stringify(jsonDoc, null, " "), + fileName: ExportHelper.getFileName("json"), + } as ExportedVaultAsString; } private buildCsvExport(decFolders: FolderView[], decCiphers: CipherView[]): string { diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.abstraction.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.abstraction.ts index 5c5ad0c313e..002b1c2d5dc 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.abstraction.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.abstraction.ts @@ -1,16 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore +import { ExportedVaultAsString } from "../types"; + import { ExportFormat } from "./vault-export.service.abstraction"; export abstract class OrganizationVaultExportServiceAbstraction { - getPasswordProtectedExport: ( + abstract getPasswordProtectedExport: ( organizationId: string, password: string, onlyManagedCollections: boolean, - ) => Promise; - getOrganizationExport: ( + ) => Promise; + abstract getOrganizationExport: ( organizationId: string, format: ExportFormat, onlyManagedCollections: boolean, - ) => Promise; + ) => Promise; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index 0961347664d..f9ecd778c23 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -30,9 +30,11 @@ import { BitwardenCsvOrgExportType, BitwardenEncryptedOrgJsonExport, BitwardenUnEncryptedOrgJsonExport, + ExportedVaultAsString, } from "../types"; import { BaseVaultExportService } from "./base-vault-export.service"; +import { ExportHelper } from "./export-helper"; import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction"; import { ExportFormat } from "./vault-export.service.abstraction"; @@ -54,38 +56,70 @@ export class OrganizationVaultExportService super(pinService, encryptService, cryptoFunctionService, kdfConfigService); } + /** Creates a password protected export of an organizational vault. + * @param organizationId The organization id + * @param password The password to protect the export + * @param onlyManagedCollections If true only managed collections will be exported + * @returns The exported vault + */ async getPasswordProtectedExport( organizationId: string, password: string, onlyManagedCollections: boolean, - ): Promise { - const clearText = await this.getOrganizationExport( + ): Promise { + const exportVault = await this.getOrganizationExport( organizationId, "json", onlyManagedCollections, ); - return this.buildPasswordExport(clearText, password); + return { + type: "text/plain", + data: await this.buildPasswordExport(exportVault.data, password), + fileName: ExportHelper.getFileName("org", "encrypted_json"), + } as ExportedVaultAsString; } + /** Creates an export of an organizational vault. Based on the provided format it will either be unencrypted, encrypted + * @param organizationId The organization id + * @param format The format of the export + * @param onlyManagedCollections If true only managed collections will be exported + * @returns The exported vault + * @throws Error if the format is zip + * @throws Error if the organization id is not set + * @throws Error if the format is not supported + * @throws Error if the organization policies prevent the export + */ async getOrganizationExport( organizationId: string, format: ExportFormat = "csv", onlyManagedCollections: boolean, - ): Promise { + ): Promise { if (Utils.isNullOrWhitespace(organizationId)) { throw new Error("OrganizationId must be set"); } - if (format === "encrypted_json") { - return onlyManagedCollections - ? this.getEncryptedManagedExport(organizationId) - : this.getOrganizationEncryptedExport(organizationId); + if (format === "zip") { + throw new Error("Zip export not supported for organization"); } - return onlyManagedCollections - ? this.getDecryptedManagedExport(organizationId, format) - : this.getOrganizationDecryptedExport(organizationId, format); + if (format === "encrypted_json") { + return { + type: "text/plain", + data: onlyManagedCollections + ? await this.getEncryptedManagedExport(organizationId) + : await this.getOrganizationEncryptedExport(organizationId), + fileName: ExportHelper.getFileName("org", "json"), + } as ExportedVaultAsString; + } + + return { + type: "text/plain", + data: onlyManagedCollections + ? await this.getDecryptedManagedExport(organizationId, format) + : await this.getOrganizationDecryptedExport(organizationId, format), + fileName: ExportHelper.getFileName("org", format), + } as ExportedVaultAsString; } private async getOrganizationDecryptedExport( diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts index df827fb3e60..d41f1aa0a10 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts @@ -1,15 +1,14 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -export const EXPORT_FORMATS = ["csv", "json", "encrypted_json"] as const; +import { ExportedVault } from "../types"; + +export const EXPORT_FORMATS = ["csv", "json", "encrypted_json", "zip"] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; export abstract class VaultExportServiceAbstraction { - getExport: (format: ExportFormat, password: string) => Promise; - getOrganizationExport: ( + abstract getExport: (format: ExportFormat, password: string) => Promise; + abstract getOrganizationExport: ( organizationId: string, format: ExportFormat, password: string, onlyManagedCollections?: boolean, - ) => Promise; - getFileName: (prefix?: string, extension?: string) => string; + ) => Promise; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts index fb44296f632..6adaa7de3ba 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.spec.ts @@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; @@ -27,6 +28,7 @@ import { } from "@bitwarden/key-management"; import { BuildTestObject, GetUniqueString } from "../../../../../../common/spec"; +import { ExportedVault, ExportedVaultAsString } from "../types"; import { IndividualVaultExportService } from "./individual-vault-export.service"; @@ -156,6 +158,7 @@ describe("VaultExportService", () => { let encryptService: MockProxy; let accountService: MockProxy; let kdfConfigService: MockProxy; + let apiService: MockProxy; beforeEach(() => { cryptoFunctionService = mock(); @@ -165,6 +168,7 @@ describe("VaultExportService", () => { keyService = mock(); encryptService = mock(); accountService = mock(); + apiService = mock(); kdfConfigService = mock(); @@ -191,6 +195,7 @@ describe("VaultExportService", () => { cryptoFunctionService, kdfConfigService, accountService, + apiService, ); }); @@ -198,35 +203,40 @@ describe("VaultExportService", () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); const actual = await exportService.getExport("json"); - - expectEqualCiphers(UserCipherViews.slice(0, 1), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherViews.slice(0, 1), exportedData.data); }); it("exports encrypted json user ciphers", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1)); const actual = await exportService.getExport("encrypted_json"); - - expectEqualCiphers(UserCipherDomains.slice(0, 1), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherDomains.slice(0, 1), exportedData.data); }); it("does not unencrypted export trashed user items", async () => { cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews); const actual = await exportService.getExport("json"); - - expectEqualCiphers(UserCipherViews.slice(0, 2), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherViews.slice(0, 2), exportedData.data); }); it("does not encrypted export trashed user items", async () => { cipherService.getAll.mockResolvedValue(UserCipherDomains); const actual = await exportService.getExport("encrypted_json"); - - expectEqualCiphers(UserCipherDomains.slice(0, 2), actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualCiphers(UserCipherDomains.slice(0, 2), exportedData.data); }); describe("password protected export", () => { + let exportedVault: ExportedVault; let exportString: string; let exportObject: any; let mac: MockProxy; @@ -245,7 +255,9 @@ describe("VaultExportService", () => { jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(salt); cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1)); - exportString = await exportService.getPasswordProtectedExport(password); + exportedVault = await exportService.getPasswordProtectedExport(password); + expect(typeof exportedVault.data).toBe("string"); + exportString = (exportedVault as ExportedVaultAsString).data; exportObject = JSON.parse(exportString); }); @@ -271,23 +283,32 @@ describe("VaultExportService", () => { it("has a mac property", async () => { encryptService.encrypt.mockResolvedValue(mac); - exportString = await exportService.getPasswordProtectedExport(password); - exportObject = JSON.parse(exportString); + exportedVault = await exportService.getPasswordProtectedExport(password); + + expect(typeof exportedVault.data).toBe("string"); + exportString = (exportedVault as ExportedVaultAsString).data; + exportObject = JSON.parse(exportString); expect(exportObject.encKeyValidation_DO_NOT_EDIT).toEqual(mac.encryptedString); }); it("has data property", async () => { encryptService.encrypt.mockResolvedValue(data); - exportString = await exportService.getPasswordProtectedExport(password); - exportObject = JSON.parse(exportString); + exportedVault = await exportService.getPasswordProtectedExport(password); + + expect(typeof exportedVault.data).toBe("string"); + exportString = (exportedVault as ExportedVaultAsString).data; + exportObject = JSON.parse(exportString); expect(exportObject.data).toEqual(data.encryptedString); }); it("encrypts the data property", async () => { - const unencrypted = await exportService.getExport(); - expect(exportObject.data).not.toEqual(unencrypted); + const unEncryptedExportVault = await exportService.getExport(); + + expect(typeof unEncryptedExportVault.data).toBe("string"); + const unEncryptedExportString = (unEncryptedExportVault as ExportedVaultAsString).data; + expect(exportObject.data).not.toEqual(unEncryptedExportString); }); }); }); @@ -297,7 +318,9 @@ describe("VaultExportService", () => { const actual = await exportService.getExport("json"); - expectEqualFolderViews(UserFolderViews, actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualFolderViews(UserFolderViews, exportedData.data); }); it("exported encrypted json contains folders", async () => { @@ -305,7 +328,9 @@ describe("VaultExportService", () => { const actual = await exportService.getExport("encrypted_json"); - expectEqualFolders(UserFolders, actual); + expect(typeof actual.data).toBe("string"); + const exportedData = actual as ExportedVaultAsString; + expectEqualFolders(UserFolders, exportedData.data); }); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts index 110f34bc458..8af961e2beb 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts @@ -1,8 +1,7 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { ExportHelper } from "./export-helper"; +import { ExportedVault } from "../types"; + import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction"; import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction"; import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction"; @@ -13,7 +12,13 @@ export class VaultExportService implements VaultExportServiceAbstraction { private organizationVaultExportService: OrganizationVaultExportServiceAbstraction, ) {} - async getExport(format: ExportFormat = "csv", password: string): Promise { + /** Creates an export of an individual vault (My vault). Based on the provided format it will either be unencrypted, encrypted or password protected + * @param format The format of the export + * @param password An optional password if the export should be password-protected + * @returns The exported vault + * @throws Error if the format is csv and a password is provided + */ + async getExport(format: ExportFormat = "csv", password: string = ""): Promise { if (!Utils.isNullOrWhitespace(password)) { if (format == "csv") { throw new Error("CSV does not support password protected export"); @@ -24,12 +29,24 @@ export class VaultExportService implements VaultExportServiceAbstraction { return this.individualVaultExportService.getExport(format); } + /** Creates an export of an organizational vault. Based on the provided format it will either be unencrypted, encrypted or password protected + * @param organizationId The organization id + * @param format The format of the export + * @param password The password to protect the export + * @param onlyManagedCollections If true only managed collections will be exported + * @returns The exported vault + * @throws Error if the format is csv and a password is provided + * @throws Error if the format is zip and the environment does not support exporting attachments + * @throws Error if the format is not supported + * @throws Error if the organization id is not a valid guid + * @throws Error if the organization policies prevent the export + */ async getOrganizationExport( organizationId: string, format: ExportFormat, password: string, onlyManagedCollections = false, - ): Promise { + ): Promise { if (!Utils.isNullOrWhitespace(password)) { if (format == "csv") { throw new Error("CSV does not support password protected export"); @@ -48,8 +65,4 @@ export class VaultExportService implements VaultExportServiceAbstraction { onlyManagedCollections, ); } - - getFileName(prefix: string = null, extension = "csv"): string { - return ExportHelper.getFileName(prefix, extension); - } } diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/exported-vault-type.ts b/libs/tools/export/vault-export/vault-export-core/src/types/exported-vault-type.ts new file mode 100644 index 00000000000..b7e6d770f0d --- /dev/null +++ b/libs/tools/export/vault-export/vault-export-core/src/types/exported-vault-type.ts @@ -0,0 +1,13 @@ +export type ExportedVaultAsBlob = { + type: "application/zip"; + data: Blob; + fileName: string; +}; + +export type ExportedVaultAsString = { + type: "text/plain"; + data: string; + fileName: string; +}; + +export type ExportedVault = ExportedVaultAsBlob | ExportedVaultAsString; diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/index.ts b/libs/tools/export/vault-export/vault-export-core/src/types/index.ts index 172c88e95d4..9be70301934 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/types/index.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/types/index.ts @@ -1,2 +1,3 @@ export * from "./bitwarden-csv-export-type"; export * from "./bitwarden-json-export-types"; +export * from "./exported-vault-type"; diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts index 76b5a893841..cb16c759ba2 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input, OnInit } from "@angular/core"; +import { Component, effect, input } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -19,7 +19,7 @@ import { CalloutModule } from "@bitwarden/components"; standalone: true, imports: [CommonModule, JslibModule, CalloutModule], }) -export class ExportScopeCalloutComponent implements OnInit { +export class ExportScopeCalloutComponent { show = false; scopeConfig: { title: string; @@ -27,35 +27,23 @@ export class ExportScopeCalloutComponent implements OnInit { scopeIdentifier: string; }; - private _organizationId: string; - - get organizationId(): string { - return this._organizationId; - } - - @Input() set organizationId(value: string) { - this._organizationId = value; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.getScopeMessage(this._organizationId); - } + /* Optional OrganizationId, if not provided, it will display individual vault export message */ + readonly organizationId = input(); + /* Optional export format, determines which individual export description to display */ + readonly exportFormat = input(); constructor( protected organizationService: OrganizationService, protected accountService: AccountService, - ) {} - - async ngOnInit(): Promise { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - if (!(await firstValueFrom(this.organizationService.hasOrganizations(userId)))) { - return; - } - - await this.getScopeMessage(this.organizationId); - this.show = true; + ) { + effect(async () => { + this.show = false; + await this.getScopeMessage(this.organizationId(), this.exportFormat()); + this.show = true; + }); } - private async getScopeMessage(organizationId: string) { + private async getScopeMessage(organizationId: string, exportFormat: string): Promise { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.scopeConfig = organizationId != null @@ -72,7 +60,10 @@ export class ExportScopeCalloutComponent implements OnInit { } : { title: "exportingPersonalVaultTitle", - description: "exportingIndividualVaultDescription", + description: + exportFormat == "zip" + ? "exportingIndividualVaultWithAttachmentsDescription" + : "exportingIndividualVaultDescription", scopeIdentifier: await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.email)), ), diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index 5b196f51799..d6b1bbe216a 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -5,7 +5,10 @@ > {{ "personalVaultExportPolicyInEffect" | i18n }}
- +
diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index 34524aaae72..f808ff6802a 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -40,6 +40,8 @@ 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 { EventType } from "@bitwarden/common/enums"; +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"; @@ -59,7 +61,7 @@ import { } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core"; -import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -183,6 +185,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { private onlyManagedCollections = true; private onGenerate$ = new Subject(); + private isExportAttachmentsEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.ExportAttachments, + ); + constructor( protected i18nService: I18nService, protected toastService: ToastService, @@ -197,6 +203,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { protected organizationService: OrganizationService, private accountService: AccountService, private collectionService: CollectionService, + private configService: ConfigService, ) {} async ngOnInit() { @@ -305,10 +312,20 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { ) .subscribe(); - this.exportForm.controls.vaultSelector.valueChanges + combineLatest([ + this.exportForm.controls.vaultSelector.valueChanges, + this.isExportAttachmentsEnabled$, + ]) .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.organizationId = value != "myVault" ? value : undefined; + .subscribe(([value, isExportAttachmentsEnabled]) => { + this.organizationId = value !== "myVault" ? value : undefined; + if (value === "myVault" && isExportAttachmentsEnabled) { + if (!this.formatOptions.some((option) => option.value === "zip")) { + this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); + } + } else { + this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip"); + } }); } @@ -344,7 +361,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { protected async doExport() { try { const data = await this.getExportData(); + + // Download the export file this.downloadFile(data); + this.toastService.showToast({ variant: "success", title: null, @@ -429,7 +449,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { return true; } - protected async getExportData(): Promise { + protected async getExportData(): Promise { return Utils.isNullOrWhitespace(this.organizationId) ? this.exportService.getExport(this.format, this.filePassword) : this.exportService.getOrganizationExport( @@ -440,23 +460,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { ); } - protected getFileName(prefix?: string) { - if (this.organizationId) { - prefix = "org"; - } - - let extension = this.format; - if (this.format === "encrypted_json") { - if (prefix == null) { - prefix = "encrypted"; - } else { - prefix = "encrypted_" + prefix; - } - extension = "json"; - } - return this.exportService.getFileName(prefix, extension); - } - protected async collectEvent(): Promise { if (this.organizationId) { return await this.eventCollectionService.collect( @@ -498,12 +501,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } } - private downloadFile(csv: string): void { - const fileName = this.getFileName(); + private downloadFile(exportedVault: ExportedVault): void { this.fileDownloadService.download({ - fileName: fileName, - blobData: csv, - blobOptions: { type: "text/plain" }, + fileName: exportedVault.fileName, + blobData: exportedVault.data, + blobOptions: { type: exportedVault.type }, }); } } From a9fd16968f67b817422adaf29c796053ed3a6559 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:11:45 -0400 Subject: [PATCH 008/228] [PM-14999] Remove Sequentialize (#12113) * Remove Sequentialize * Delete `clearCaches` --- .../browser/src/background/main.background.ts | 3 - .../services/lowdb-storage.service.ts | 2 - apps/desktop/src/app/app.component.ts | 3 - .../src/platform/misc/sequentialize.spec.ts | 139 ------------------ .../common/src/platform/misc/sequentialize.ts | 64 -------- .../common/src/platform/misc/throttle.spec.ts | 13 -- .../src/platform/sync/default-sync.service.ts | 2 - .../src/vault/services/cipher.service.ts | 2 - 8 files changed, 228 deletions(-) delete mode 100644 libs/common/src/platform/misc/sequentialize.spec.ts delete mode 100644 libs/common/src/platform/misc/sequentialize.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index f37f0a3c440..a396123f830 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -115,7 +115,6 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf // 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 { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -1442,8 +1441,6 @@ export default class MainBackground { await this.popupViewCacheBackgroundService.clearState(); await this.accountService.switchAccount(userId); await switchPromise; - // Clear sequentialized caches - clearCaches(); if (userId == null) { await this.refreshBadge(); diff --git a/apps/cli/src/platform/services/lowdb-storage.service.ts b/apps/cli/src/platform/services/lowdb-storage.service.ts index f4542236395..61d3920c918 100644 --- a/apps/cli/src/platform/services/lowdb-storage.service.ts +++ b/apps/cli/src/platform/services/lowdb-storage.service.ts @@ -14,7 +14,6 @@ import { AbstractStorageService, StorageUpdate, } from "@bitwarden/common/platform/abstractions/storage.service"; -import { sequentialize } from "@bitwarden/common/platform/misc/sequentialize"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -44,7 +43,6 @@ export class LowdbStorageService implements AbstractStorageService { this.updates$ = this.updatesSubject.asObservable(); } - @sequentialize(() => "lowdbStorageInit") async init() { if (this.ready) { return; diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 924bc2dd30f..e0075975900 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -49,7 +49,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; -import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize"; import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -406,8 +405,6 @@ export class AppComponent implements OnInit, OnDestroy { this.router.navigate(["/remove-password"]); break; case "switchAccount": { - // Clear sequentialized caches - clearCaches(); if (message.userId != null) { await this.accountService.switchAccount(message.userId); } diff --git a/libs/common/src/platform/misc/sequentialize.spec.ts b/libs/common/src/platform/misc/sequentialize.spec.ts deleted file mode 100644 index 0b79cde387e..00000000000 --- a/libs/common/src/platform/misc/sequentialize.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { clearCaches, sequentialize } from "./sequentialize"; - -describe("sequentialize decorator", () => { - it("should call the function once", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.bar(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(1); - }); - - it("should call the function once for each instance of the object", async () => { - const foo = new Foo(); - const foo2 = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.bar(1)); - promises.push(foo2.bar(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(1); - expect(foo2.calls).toBe(1); - }); - - it("should call the function once with key function", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.baz(1)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(1); - }); - - it("should call the function again when already resolved", async () => { - const foo = new Foo(); - await foo.bar(1); - expect(foo.calls).toBe(1); - await foo.bar(1); - expect(foo.calls).toBe(2); - }); - - it("should call the function again when already resolved with a key function", async () => { - const foo = new Foo(); - await foo.baz(1); - expect(foo.calls).toBe(1); - await foo.baz(1); - expect(foo.calls).toBe(2); - }); - - it("should call the function for each argument", async () => { - const foo = new Foo(); - await Promise.all([foo.bar(1), foo.bar(1), foo.bar(2), foo.bar(2), foo.bar(3), foo.bar(3)]); - expect(foo.calls).toBe(3); - }); - - it("should call the function for each argument with key function", async () => { - const foo = new Foo(); - await Promise.all([foo.baz(1), foo.baz(1), foo.baz(2), foo.baz(2), foo.baz(3), foo.baz(3)]); - expect(foo.calls).toBe(3); - }); - - it("should return correct result for each call", async () => { - const foo = new Foo(); - const allRes: number[] = []; - - await Promise.all([ - foo.bar(1).then((res) => allRes.push(res)), - foo.bar(1).then((res) => allRes.push(res)), - foo.bar(2).then((res) => allRes.push(res)), - foo.bar(2).then((res) => allRes.push(res)), - foo.bar(3).then((res) => allRes.push(res)), - foo.bar(3).then((res) => allRes.push(res)), - ]); - expect(foo.calls).toBe(3); - expect(allRes.length).toBe(6); - allRes.sort(); - expect(allRes).toEqual([2, 2, 4, 4, 6, 6]); - }); - - it("should return correct result for each call with key function", async () => { - const foo = new Foo(); - const allRes: number[] = []; - - await Promise.all([ - foo.baz(1).then((res) => allRes.push(res)), - foo.baz(1).then((res) => allRes.push(res)), - foo.baz(2).then((res) => allRes.push(res)), - foo.baz(2).then((res) => allRes.push(res)), - foo.baz(3).then((res) => allRes.push(res)), - foo.baz(3).then((res) => allRes.push(res)), - ]); - expect(foo.calls).toBe(3); - expect(allRes.length).toBe(6); - allRes.sort(); - expect(allRes).toEqual([3, 3, 6, 6, 9, 9]); - }); - - describe("clearCaches", () => { - it("should clear all caches", async () => { - const foo = new Foo(); - const promise = Promise.all([foo.bar(1), foo.bar(1)]); - clearCaches(); - await foo.bar(1); - await promise; - // one call for the first two, one for the third after the cache was cleared - expect(foo.calls).toBe(2); - }); - }); -}); - -class Foo { - calls = 0; - - @sequentialize((args) => "bar" + args[0]) - bar(a: number): Promise { - this.calls++; - return new Promise((res) => { - setTimeout(() => { - res(a * 2); - }, Math.random() * 100); - }); - } - - @sequentialize((args) => "baz" + args[0]) - baz(a: number): Promise { - this.calls++; - return new Promise((res) => { - setTimeout(() => { - res(a * 3); - }, Math.random() * 100); - }); - } -} diff --git a/libs/common/src/platform/misc/sequentialize.ts b/libs/common/src/platform/misc/sequentialize.ts deleted file mode 100644 index 0400c8b1d0b..00000000000 --- a/libs/common/src/platform/misc/sequentialize.ts +++ /dev/null @@ -1,64 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -const caches = new Map>>(); - -const getCache = (obj: any) => { - let cache = caches.get(obj); - if (cache != null) { - return cache; - } - cache = new Map>(); - caches.set(obj, cache); - return cache; -}; - -export function clearCaches() { - caches.clear(); -} - -/** - * Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time - * - * If a promise was returned from a previous call to this function, that hasn't yet resolved it will - * be returned, instead of calling the original function again - * - * Results are not cached, once the promise has returned, the next call will result in a fresh call - * - * Read more at https://github.com/bitwarden/jslib/pull/7 - */ -export function sequentialize(cacheKey: (args: any[]) => string) { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod: () => Promise = descriptor.value; - - return { - value: function (...args: any[]) { - const cache = getCache(this); - const argsCacheKey = cacheKey(args); - let response = cache.get(argsCacheKey); - if (response != null) { - return response; - } - - const onFinally = () => { - cache.delete(argsCacheKey); - if (cache.size === 0) { - caches.delete(this); - } - }; - response = originalMethod - .apply(this, args) - .then((val: any) => { - onFinally(); - return val; - }) - .catch((err: any) => { - onFinally(); - throw err; - }); - - cache.set(argsCacheKey, response); - return response; - }, - }; - }; -} diff --git a/libs/common/src/platform/misc/throttle.spec.ts b/libs/common/src/platform/misc/throttle.spec.ts index 0947d4af664..1c1ff6324a6 100644 --- a/libs/common/src/platform/misc/throttle.spec.ts +++ b/libs/common/src/platform/misc/throttle.spec.ts @@ -1,4 +1,3 @@ -import { sequentialize } from "./sequentialize"; import { throttle } from "./throttle"; describe("throttle decorator", () => { @@ -51,17 +50,6 @@ describe("throttle decorator", () => { expect(foo.calls).toBe(10); expect(foo2.calls).toBe(10); }); - - it("should work together with sequentialize", async () => { - const foo = new Foo(); - const promises = []; - for (let i = 0; i < 10; i++) { - promises.push(foo.qux(Math.floor(i / 2) * 2)); - } - await Promise.all(promises); - - expect(foo.calls).toBe(5); - }); }); class Foo { @@ -94,7 +82,6 @@ class Foo { }); } - @sequentialize((args) => "qux" + args[0]) @throttle(1, () => "qux") qux(a: number) { this.calls++; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 656267d864c..30a59e9c165 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -51,7 +51,6 @@ import { FolderResponse } from "../../vault/models/response/folder.response"; import { LogService } from "../abstractions/log.service"; import { StateService } from "../abstractions/state.service"; import { MessageSender } from "../messaging"; -import { sequentialize } from "../misc/sequentialize"; import { StateProvider } from "../state"; import { CoreSyncService } from "./core-sync.service"; @@ -103,7 +102,6 @@ export class DefaultSyncService extends CoreSyncService { ); } - @sequentialize(() => "fullSync") override async fullSync(forceSync: boolean, allowThrowOnError = false): Promise { const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); this.syncStarted(); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d774277c4a0..4876a755ed8 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -31,7 +31,6 @@ import { View } from "../../models/view/view"; import { ConfigService } from "../../platform/abstractions/config/config.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; -import { sequentialize } from "../../platform/misc/sequentialize"; import { Utils } from "../../platform/misc/utils"; import Domain from "../../platform/models/domain/domain-base"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; @@ -390,7 +389,6 @@ export class CipherService implements CipherServiceAbstraction { * cached, the cached ciphers are returned. * @deprecated Use `cipherViews$` observable instead */ - @sequentialize(() => "getAllDecrypted") async getAllDecrypted(userId: UserId): Promise { const decCiphers = await this.getDecryptedCiphers(userId); if (decCiphers != null && decCiphers.length !== 0) { From 0fd01ed7ee0e60de24cd693717aa353c0aa04eda Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 25 Mar 2025 11:30:47 -0400 Subject: [PATCH 009/228] [PM-18566] Wire up vNextPolicyService for Clients (#13678) * wire up vNext impl * wire up vNextPolicyService for browser * wire up vNextPolicyService for desktop * wire up vNextPolicyService for cli * fix test * fix missed caller * cleanup * fix missing property assignment * fix QA bug for PM-19205 * fix QA bug for PM-19206 * fix QA bug for pm-19228 * cleanup --- .../account-security.component.spec.ts | 14 +- .../settings/account-security.component.ts | 19 +- .../auto-submit-login.background.spec.ts | 26 +- .../auto-submit-login.background.ts | 23 +- .../notification.background.spec.ts | 4 +- .../background/notification.background.ts | 11 +- .../browser/src/background/main.background.ts | 12 +- .../services/families-policy.service.spec.ts | 4 +- .../services/families-policy.service.ts | 2 +- .../src/popup/services/services.module.ts | 2 +- .../popup/send-v2/send-v2.component.spec.ts | 2 +- .../tools/popup/send-v2/send-v2.component.ts | 16 +- .../vault-popup-list-filters.service.spec.ts | 21 +- .../vault-popup-list-filters.service.ts | 112 ++-- apps/cli/src/auth/commands/login.command.ts | 8 +- .../service-container/service-container.ts | 13 +- apps/cli/src/tools/export.command.ts | 16 +- apps/cli/src/vault.program.ts | 1 + .../app/accounts/settings.component.spec.ts | 18 +- .../src/app/accounts/settings.component.ts | 16 +- .../encrypted-message-handler.service.ts | 14 +- .../vault-filter/vault-filter.component.ts | 3 + .../layouts/organization-layout.component.ts | 5 +- .../components/reset-password.component.ts | 14 +- .../members/members.component.ts | 10 +- .../login/web-login-component.service.spec.ts | 8 + .../login/web-login-component.service.ts | 10 +- .../web-registration-finish.service.spec.ts | 7 + .../web-registration-finish.service.ts | 4 +- .../emergency-access-takeover.component.ts | 12 +- .../two-factor/two-factor-setup.component.ts | 13 +- .../webauthn-login-settings.component.ts | 16 +- .../change-plan-dialog.component.ts | 14 +- .../organization-plans.component.ts | 14 +- .../services/free-families-policy.service.ts | 2 +- .../settings/sponsored-families.component.ts | 2 +- .../settings/sponsoring-org-row.component.ts | 4 +- .../complete-trial-initiation.component.ts | 14 +- apps/web/src/app/core/core.module.ts | 2 + apps/web/src/app/core/event.service.ts | 15 +- .../src/app/settings/preferences.component.ts | 21 +- .../organization-options.component.ts | 6 +- .../components/vault-filter.component.ts | 39 +- .../services/vault-filter.service.spec.ts | 8 +- .../services/vault-filter.service.ts | 10 +- .../vault-onboarding.component.spec.ts | 6 +- .../vault-onboarding.component.ts | 15 +- ...console-cipher-form-config.service.spec.ts | 10 +- ...dmin-console-cipher-form-config.service.ts | 11 +- .../domain-verification.component.ts | 8 +- .../components/change-password.component.ts | 12 +- .../src/services/jslib-services.module.ts | 9 +- .../src/tools/send/add-edit.component.ts | 13 +- libs/angular/src/tools/send/send.component.ts | 24 +- .../vault/components/add-edit.component.ts | 9 +- .../services/vault-filter.service.ts | 14 +- .../vault-timeout-input.component.spec.ts | 11 +- .../vault-timeout-input.component.ts | 15 +- .../policy/policy-api.service.abstraction.ts | 18 +- .../policy/policy.service.abstraction.ts | 51 +- .../policy/vnext-policy.service.ts | 68 --- ...spec.ts => default-policy.service.spec.ts} | 8 +- ...y.service.ts => default-policy.service.ts} | 12 +- .../services/policy/policy-api.service.ts | 18 +- ...{vnext-policy-state.ts => policy-state.ts} | 0 .../services/policy/policy.service.spec.ts | 556 ------------------ .../services/policy/policy.service.ts | 257 -------- .../services/autofill-settings.service.ts | 12 +- .../vault-timeout-settings.service.spec.ts | 6 +- .../vault-timeout-settings.service.ts | 6 +- .../src/components/import.component.ts | 7 +- .../src/lock/components/lock.component.ts | 6 +- .../src/components/export.component.ts | 35 +- .../credential-generator.service.spec.ts | 70 ++- .../services/credential-generator.service.ts | 14 +- .../default-generator.service.spec.ts | 18 +- .../src/services/default-generator.service.ts | 2 +- .../generator-profile-provider.spec.ts | 8 +- .../services/generator-profile-provider.ts | 2 +- ...fault-generator-navigation.service.spec.ts | 4 +- .../default-generator-navigation.service.ts | 2 +- .../options/send-options.component.ts | 2 +- .../default-send-form-config.service.ts | 13 +- .../default-cipher-form-config.service.ts | 10 +- 84 files changed, 723 insertions(+), 1246 deletions(-) delete mode 100644 libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts rename libs/common/src/admin-console/services/policy/{default-vnext-policy.service.spec.ts => default-policy.service.spec.ts} (98%) rename libs/common/src/admin-console/services/policy/{default-vnext-policy.service.ts => default-policy.service.ts} (95%) rename libs/common/src/admin-console/services/policy/{vnext-policy-state.ts => policy-state.ts} (100%) delete mode 100644 libs/common/src/admin-console/services/policy/policy.service.spec.ts delete mode 100644 libs/common/src/admin-console/services/policy/policy.service.ts diff --git a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts index 60b38570482..abe642970bb 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.spec.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.spec.ts @@ -99,7 +99,7 @@ describe("AccountSecurityComponent", () => { it("pin enabled when RemoveUnlockWithPin policy is not set", async () => { // @ts-strict-ignore - policyService.get$.mockReturnValue(of(null)); + policyService.policiesByType$.mockReturnValue(of([null])); await component.ngOnInit(); @@ -111,7 +111,7 @@ describe("AccountSecurityComponent", () => { policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = false; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); @@ -129,7 +129,7 @@ describe("AccountSecurityComponent", () => { policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); @@ -143,7 +143,7 @@ describe("AccountSecurityComponent", () => { it("pin visible when RemoveUnlockWithPin policy is not set", async () => { // @ts-strict-ignore - policyService.get$.mockReturnValue(of(null)); + policyService.policiesByType$.mockReturnValue(of([null])); await component.ngOnInit(); fixture.detectChanges(); @@ -158,7 +158,7 @@ describe("AccountSecurityComponent", () => { policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = false; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); fixture.detectChanges(); @@ -173,7 +173,7 @@ describe("AccountSecurityComponent", () => { policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); pinServiceAbstraction.isPinSet.mockResolvedValue(true); @@ -190,7 +190,7 @@ describe("AccountSecurityComponent", () => { policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); fixture.detectChanges(); 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 8cdfdab9524..75b59b8efdc 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -27,8 +27,10 @@ import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitward import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DeviceType } from "@bitwarden/common/enums"; import { VaultTimeout, @@ -152,8 +154,14 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); this.showMasterPasswordOnClientRestartOption = hasMasterPassword; - const maximumVaultTimeoutPolicy = this.policyService.get$(PolicyType.MaximumVaultTimeout); - if ((await firstValueFrom(this.policyService.get$(PolicyType.MaximumVaultTimeout))) != null) { + const maximumVaultTimeoutPolicy = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + ); + if ((await firstValueFrom(maximumVaultTimeoutPolicy)) != null) { this.hasVaultTimeoutPolicy = true; } @@ -195,7 +203,12 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { timeout = VaultTimeoutStringType.OnRestart; } - this.pinEnabled$ = this.policyService.get$(PolicyType.RemoveUnlockWithPin).pipe( + this.pinEnabled$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId), + ), + getFirstPolicy, map((policy) => { return policy == null || !policy.enabled; }), 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 a300ac08660..9f197b02193 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 @@ -8,6 +8,9 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio 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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; @@ -35,10 +38,12 @@ describe("AutoSubmitLoginBackground", () => { let configService: MockProxy; let platformUtilsService: MockProxy; let policyDetails: MockProxy; - let automaticAppLogInPolicy$: BehaviorSubject; - let policyAppliesToActiveUser$: BehaviorSubject; + let automaticAppLogInPolicy$: BehaviorSubject; + let policyAppliesToUser$: BehaviorSubject; let policyService: MockProxy; let autoSubmitLoginBackground: AutoSubmitLoginBackground; + let accountService: FakeAccountService; + const mockUserId = Utils.newGuid() as UserId; const validIpdUrl1 = "https://example.com"; const validIpdUrl2 = "https://subdomain.example3.com"; const validAutoSubmitHost = "some-valid-url.com"; @@ -61,12 +66,13 @@ describe("AutoSubmitLoginBackground", () => { idpHost: `${validIpdUrl1} , https://example2.com/some/sub-route ,${validIpdUrl2}, [invalidValue] ,,`, }, }); - automaticAppLogInPolicy$ = new BehaviorSubject(policyDetails); - policyAppliesToActiveUser$ = new BehaviorSubject(true); + automaticAppLogInPolicy$ = new BehaviorSubject([policyDetails]); + policyAppliesToUser$ = new BehaviorSubject(true); policyService = mock({ - get$: jest.fn().mockReturnValue(automaticAppLogInPolicy$), - policyAppliesToActiveUser$: jest.fn().mockReturnValue(policyAppliesToActiveUser$), + policiesByType$: jest.fn().mockReturnValue(automaticAppLogInPolicy$), + policyAppliesToUser$: jest.fn().mockReturnValue(policyAppliesToUser$), }); + accountService = mockAccountServiceWith(mockUserId); autoSubmitLoginBackground = new AutoSubmitLoginBackground( logService, autofillService, @@ -75,6 +81,7 @@ describe("AutoSubmitLoginBackground", () => { configService, platformUtilsService, policyService, + accountService, ); }); @@ -84,7 +91,7 @@ describe("AutoSubmitLoginBackground", () => { describe("when the AutoSubmitLoginBackground feature is disabled", () => { it("destroys all event listeners when the AutomaticAppLogIn policy is not enabled", async () => { - automaticAppLogInPolicy$.next(mock({ ...policyDetails, enabled: false })); + automaticAppLogInPolicy$.next([mock({ ...policyDetails, enabled: false })]); await autoSubmitLoginBackground.init(); @@ -92,7 +99,7 @@ describe("AutoSubmitLoginBackground", () => { }); it("destroys all event listeners when the AutomaticAppLogIn policy does not apply to the current user", async () => { - policyAppliesToActiveUser$.next(false); + policyAppliesToUser$.next(false); await autoSubmitLoginBackground.init(); @@ -100,7 +107,7 @@ describe("AutoSubmitLoginBackground", () => { }); it("destroys all event listeners when the idpHost is not specified in the AutomaticAppLogIn policy", async () => { - automaticAppLogInPolicy$.next(mock({ ...policyDetails, data: { idpHost: "" } })); + automaticAppLogInPolicy$.next([mock({ ...policyDetails, data: { idpHost: "" } })]); await autoSubmitLoginBackground.init(); @@ -264,6 +271,7 @@ describe("AutoSubmitLoginBackground", () => { configService, platformUtilsService, policyService, + accountService, ); jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(tab); }); 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 034938ca521..bce876e8f82 100644 --- a/apps/browser/src/autofill/background/auto-submit-login.background.ts +++ b/apps/browser/src/autofill/background/auto-submit-login.background.ts @@ -1,12 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -42,6 +45,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr private configService: ConfigService, private platformUtilsService: PlatformUtilsService, private policyService: PolicyService, + private accountService: AccountService, ) { this.isSafariBrowser = this.platformUtilsService.isSafari(); } @@ -56,8 +60,14 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr FeatureFlag.IdpAutoSubmitLogin, ); if (featureFlagEnabled) { - this.policyService - .get$(PolicyType.AutomaticAppLogIn) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.AutomaticAppLogIn, userId), + ), + getFirstPolicy, + ) .subscribe(this.handleAutoSubmitLoginPolicySubscription.bind(this)); } } @@ -86,7 +96,12 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr */ private applyPolicyToActiveUser = async (policy: Policy) => { const policyAppliesToUser = await firstValueFrom( - this.policyService.policyAppliesToActiveUser$(PolicyType.AutomaticAppLogIn), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.AutomaticAppLogIn, userId), + ), + ), ); if (!policyAppliesToUser) { diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index d474e303336..ebdd244e140 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -2,7 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; +import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; @@ -51,7 +51,7 @@ describe("NotificationBackground", () => { const cipherService = mock(); let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; - const policyService = mock(); + const policyService = mock(); const folderService = mock(); const userNotificationSettingsService = mock(); const domainSettingsService = mock(); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 50e0ee0aa75..c2e90460dfc 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -8,7 +8,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; +import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service"; import { ExtensionCommand, ExtensionCommandType, @@ -743,7 +743,12 @@ export default class NotificationBackground { private async removeIndividualVault(): Promise { return await firstValueFrom( - this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + ), ); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a396123f830..e1f0b8bfc64 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -26,8 +26,8 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { InternalPolicyService as InternalPolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { DefaultOrganizationService } from "@bitwarden/common/admin-console/services/organization/default-organization.service"; +import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -685,7 +685,7 @@ export default class MainBackground { this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); this.organizationService = new DefaultOrganizationService(this.stateProvider); - this.policyService = new PolicyService(this.stateProvider, this.organizationService); + this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService); this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, @@ -728,9 +728,14 @@ export default class MainBackground { this.autofillSettingsService = new AutofillSettingsService( this.stateProvider, this.policyService, + this.accountService, ); this.badgeSettingsService = new BadgeSettingsService(this.stateProvider); - this.policyApiService = new PolicyApiService(this.policyService, this.apiService); + this.policyApiService = new PolicyApiService( + this.policyService, + this.apiService, + this.accountService, + ); this.keyConnectorService = new KeyConnectorService( this.accountService, this.masterPasswordService, @@ -1202,6 +1207,7 @@ export default class MainBackground { this.configService, this.platformUtilsService, this.policyService, + this.accountService, ); const contextMenuClickedHandler = new ContextMenuClickedHandler( diff --git a/apps/browser/src/billing/services/families-policy.service.spec.ts b/apps/browser/src/billing/services/families-policy.service.spec.ts index 65a861038bf..e9f75d52cb6 100644 --- a/apps/browser/src/billing/services/families-policy.service.spec.ts +++ b/apps/browser/src/billing/services/families-policy.service.spec.ts @@ -51,7 +51,7 @@ describe("FamiliesPolicyService", () => { organizationService.organizations$.mockReturnValue(of(organizations)); const policies = [{ organizationId: "org1", enabled: true }] as Policy[]; - policyService.getAll$.mockReturnValue(of(policies)); + policyService.policiesByType$.mockReturnValue(of(policies)); const result = await firstValueFrom(service.isFreeFamilyPolicyEnabled$()); expect(result).toBe(true); @@ -64,7 +64,7 @@ describe("FamiliesPolicyService", () => { organizationService.organizations$.mockReturnValue(of(organizations)); const policies = [{ organizationId: "org1", enabled: false }] as Policy[]; - policyService.getAll$.mockReturnValue(of(policies)); + policyService.policiesByType$.mockReturnValue(of(policies)); const result = await firstValueFrom(service.isFreeFamilyPolicyEnabled$()); expect(result).toBe(false); diff --git a/apps/browser/src/billing/services/families-policy.service.ts b/apps/browser/src/billing/services/families-policy.service.ts index 755d3e84591..42fa43cab1d 100644 --- a/apps/browser/src/billing/services/families-policy.service.ts +++ b/apps/browser/src/billing/services/families-policy.service.ts @@ -47,7 +47,7 @@ export class FamiliesPolicyService { map((organizations) => organizations.find((org) => org.canManageSponsorships)?.id), switchMap((enterpriseOrgId) => this.policyService - .getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId) + .policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId) .pipe( map( (policies) => diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 86ab11a374a..42a05f14007 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -482,7 +482,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: AutofillSettingsServiceAbstraction, useClass: AutofillSettingsService, - deps: [StateProvider, PolicyService], + deps: [StateProvider, PolicyService, AccountService], }), safeProvider({ provide: UserNotificationSettingsServiceAbstraction, 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 c3f4634a6c2..6fc4793f5c0 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 @@ -64,7 +64,7 @@ describe("SendV2Component", () => { }); policyService = mock(); - policyService.policyAppliesToActiveUser$.mockReturnValue(of(true)); // Return `true` by default + policyService.policyAppliesToUser$.mockReturnValue(of(true)); // Return `true` by default sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder()); 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 2766ba56c95..49804abda5d 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 @@ -2,11 +2,13 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { RouterLink } from "@angular/router"; -import { combineLatest } from "rxjs"; +import { combineLatest, switchMap } 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { ButtonModule, CalloutModule, Icons, NoItemsModule } from "@bitwarden/components"; import { @@ -66,6 +68,7 @@ export class SendV2Component implements OnInit, OnDestroy { protected sendItemsService: SendItemsService, protected sendListFiltersService: SendListFiltersService, private policyService: PolicyService, + private accountService: AccountService, ) { combineLatest([ this.sendItemsService.emptyList$, @@ -93,9 +96,14 @@ export class SendV2Component implements OnInit, OnDestroy { this.listState = null; }); - this.policyService - .policyAppliesToActiveUser$(PolicyType.DisableSend) - .pipe(takeUntilDestroyed()) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId), + ), + takeUntilDestroyed(), + ) .subscribe((sendsDisabled) => { this.sendsDisabled = sendsDisabled; }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 99a27c54bcc..f9785bccd00 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -36,7 +36,7 @@ describe("VaultPopupListFiltersService", () => { let folderViews$ = new BehaviorSubject([]); const cipherViews$ = new BehaviorSubject({}); let decryptedCollections$ = new BehaviorSubject([]); - const policyAppliesToActiveUser$ = new BehaviorSubject(false); + const policyAppliesToUser$ = new BehaviorSubject(false); let viewCacheService: { signal: jest.Mock; mockSignal: WritableSignal; @@ -65,7 +65,7 @@ describe("VaultPopupListFiltersService", () => { } as I18nService; const policyService = { - policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$), + policyAppliesToUser$: jest.fn(() => policyAppliesToUser$), }; const state$ = new BehaviorSubject(false); @@ -75,8 +75,8 @@ describe("VaultPopupListFiltersService", () => { _memberOrganizations$ = new BehaviorSubject([]); // Fresh instance per test folderViews$ = new BehaviorSubject([]); // Fresh instance per test decryptedCollections$ = new BehaviorSubject([]); // Fresh instance per test - policyAppliesToActiveUser$.next(false); - policyService.policyAppliesToActiveUser$.mockClear(); + policyAppliesToUser$.next(false); + policyService.policyAppliesToUser$.mockClear(); const accountService = mockAccountServiceWith("userId" as UserId); const mockCachedSignal = createMockSignal({}); @@ -196,14 +196,15 @@ describe("VaultPopupListFiltersService", () => { }); describe("PersonalOwnership policy", () => { - it('calls policyAppliesToActiveUser$ with "PersonalOwnership"', () => { - expect(policyService.policyAppliesToActiveUser$).toHaveBeenCalledWith( + it('calls policyAppliesToUser$ with "PersonalOwnership"', () => { + expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith( PolicyType.PersonalOwnership, + "userId", ); }); it("returns an empty array when the policy applies and there is a single organization", (done) => { - policyAppliesToActiveUser$.next(true); + policyAppliesToUser$.next(true); _memberOrganizations$.next([ { name: "bobby's org", id: "1234-3323-23223" }, ] as Organization[]); @@ -215,7 +216,7 @@ describe("VaultPopupListFiltersService", () => { }); it('adds "myVault" when the policy does not apply and there are multiple organizations', (done) => { - policyAppliesToActiveUser$.next(false); + policyAppliesToUser$.next(false); const orgs = [ { name: "bobby's org", id: "1234-3323-23223" }, { name: "alice's org", id: "2223-4343-99888" }, @@ -234,7 +235,7 @@ describe("VaultPopupListFiltersService", () => { }); it('does not add "myVault" the policy applies and there are multiple organizations', (done) => { - policyAppliesToActiveUser$.next(true); + policyAppliesToUser$.next(true); const orgs = [ { name: "bobby's org", id: "1234-3323-23223" }, { name: "alice's org", id: "2223-3242-99888" }, @@ -679,7 +680,7 @@ function createSeededVaultPopupListFiltersService( } as any; const policyServiceMock = { - policyAppliesToActiveUser$: jest.fn(() => new BehaviorSubject(false)), + policyAppliesToUser$: jest.fn(() => new BehaviorSubject(false)), } as any; const stateProviderMock = { diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 8d9f6664e45..187c8772e88 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -8,7 +8,6 @@ import { filter, map, Observable, - of, shareReplay, startWith, switchMap, @@ -23,6 +22,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli 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 { 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 { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -288,68 +288,70 @@ export class VaultPopupListFiltersService { /** * Organization array structured to be directly passed to `ChipSelectComponent` */ - organizations$: Observable[]> = combineLatest([ + + organizations$: Observable[]> = this.accountService.activeAccount$.pipe( - switchMap((account) => - account === null ? of([]) : this.organizationService.memberOrganizations$(account.id), + getUserId, + switchMap((userId) => + combineLatest([ + this.organizationService.memberOrganizations$(userId), + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ]), ), - ), - this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), - ]).pipe( - map(([orgs, personalOwnershipApplies]): [Organization[], boolean] => [ - orgs.sort(Utils.getSortFunction(this.i18nService, "name")), - personalOwnershipApplies, - ]), - map(([orgs, personalOwnershipApplies]) => { - // When there are no organizations return an empty array, - // resulting in the org filter being hidden - if (!orgs.length) { - return []; - } + map(([orgs, personalOwnershipApplies]): [Organization[], boolean] => [ + orgs.sort(Utils.getSortFunction(this.i18nService, "name")), + personalOwnershipApplies, + ]), + map(([orgs, personalOwnershipApplies]) => { + // When there are no organizations return an empty array, + // resulting in the org filter being hidden + if (!orgs.length) { + return []; + } - // When there is only one organization and personal ownership policy applies, - // return an empty array, resulting in the org filter being hidden - if (orgs.length === 1 && personalOwnershipApplies) { - return []; - } + // When there is only one organization and personal ownership policy applies, + // return an empty array, resulting in the org filter being hidden + if (orgs.length === 1 && personalOwnershipApplies) { + return []; + } - const myVaultOrg: ChipSelectOption[] = []; + const myVaultOrg: ChipSelectOption[] = []; - // Only add "My vault" if personal ownership policy does not apply - if (!personalOwnershipApplies) { - myVaultOrg.push({ - value: { id: MY_VAULT_ID } as Organization, - label: this.i18nService.t("myVault"), - icon: "bwi-user", - }); - } + // Only add "My vault" if personal ownership policy does not apply + if (!personalOwnershipApplies) { + myVaultOrg.push({ + value: { id: MY_VAULT_ID } as Organization, + label: this.i18nService.t("myVault"), + icon: "bwi-user", + }); + } - return [ - ...myVaultOrg, - ...orgs.map((org) => { - let icon = "bwi-business"; + return [ + ...myVaultOrg, + ...orgs.map((org) => { + let icon = "bwi-business"; - if (!org.enabled) { - // Show a warning icon if the organization is deactivated - icon = "bwi-exclamation-triangle tw-text-danger"; - } else if ( - org.productTierType === ProductTierType.Families || - org.productTierType === ProductTierType.Free - ) { - // Show a family icon if the organization is a family or free org - icon = "bwi-family"; - } + if (!org.enabled) { + // Show a warning icon if the organization is deactivated + icon = "bwi-exclamation-triangle tw-text-danger"; + } else if ( + org.productTierType === ProductTierType.Families || + org.productTierType === ProductTierType.Free + ) { + // Show a family icon if the organization is a family or free org + icon = "bwi-family"; + } - return { - value: org, - label: org.name, - icon, - }; - }), - ]; - }), - shareReplay({ refCount: true, bufferSize: 1 }), - ); + return { + value: org, + label: org.name, + icon, + }; + }), + ]; + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); /** * Folder array structured to be directly passed to `ChipSelectComponent` diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 9af6e1f0613..107afc6dc8d 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -5,7 +5,7 @@ import * as http from "http"; import { OptionValues } from "commander"; import * as inquirer from "inquirer"; import Separator from "inquirer/lib/objects/separator"; -import { firstValueFrom, map } from "rxjs"; +import { firstValueFrom, map, switchMap } from "rxjs"; import { LoginStrategyServiceAbstraction, @@ -29,6 +29,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -555,7 +556,10 @@ export class LoginCommand { ); const enforcedPolicyOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), + ), ); // Verify master password meets policy requirements diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index c82a744b4a1..5bc07f63c32 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -28,8 +28,8 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { DefaultOrganizationService } from "@bitwarden/common/admin-console/services/organization/default-organization.service"; import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service"; +import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -237,7 +237,7 @@ export class ServiceContainer { cryptoFunctionService: NodeCryptoFunctionService; encryptService: EncryptServiceImplementation; authService: AuthService; - policyService: PolicyService; + policyService: DefaultPolicyService; policyApiService: PolicyApiServiceAbstraction; logService: ConsoleLogService; sendService: SendService; @@ -469,7 +469,7 @@ export class ServiceContainer { this.ssoUrlService = new SsoUrlService(); this.organizationService = new DefaultOrganizationService(this.stateProvider); - this.policyService = new PolicyService(this.stateProvider, this.organizationService); + this.policyService = new DefaultPolicyService(this.stateProvider, this.organizationService); this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, @@ -560,7 +560,11 @@ export class ServiceContainer { this.providerService = new ProviderService(this.stateProvider); - this.policyApiService = new PolicyApiService(this.policyService, this.apiService); + this.policyApiService = new PolicyApiService( + this.policyService, + this.apiService, + this.accountService, + ); this.keyConnectorService = new KeyConnectorService( this.accountService, @@ -672,6 +676,7 @@ export class ServiceContainer { this.autofillSettingsService = new AutofillSettingsService( this.stateProvider, this.policyService, + this.accountService, ); this.cipherService = new CipherService( diff --git a/apps/cli/src/tools/export.command.ts b/apps/cli/src/tools/export.command.ts index df6e7bae1cb..f5fea794eef 100644 --- a/apps/cli/src/tools/export.command.ts +++ b/apps/cli/src/tools/export.command.ts @@ -2,10 +2,13 @@ // @ts-strict-ignore import { OptionValues } from "commander"; import * as inquirer from "inquirer"; +import { firstValueFrom, switchMap } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; 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 { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -26,14 +29,19 @@ export class ExportCommand { private exportService: VaultExportServiceAbstraction, private policyService: PolicyService, private eventCollectionService: EventCollectionService, + private accountService: AccountService, private configService: ConfigService, ) {} async run(options: OptionValues): Promise { - if ( - options.organizationid == null && - (await this.policyService.policyAppliesToUser(PolicyType.DisablePersonalVaultExport)) - ) { + const policyApplies$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId), + ), + ); + + if (options.organizationid == null && (await firstValueFrom(policyApplies$))) { return Response.badRequest( "One or more organization policies prevents you from exporting your personal vault.", ); diff --git a/apps/cli/src/vault.program.ts b/apps/cli/src/vault.program.ts index 8b9d441f0ff..81816540d12 100644 --- a/apps/cli/src/vault.program.ts +++ b/apps/cli/src/vault.program.ts @@ -501,6 +501,7 @@ export class VaultProgram extends BaseProgram { this.serviceContainer.exportService, this.serviceContainer.policyService, this.serviceContainer.eventCollectionService, + this.serviceContainer.accountService, this.serviceContainer.configService, ); const response = await command.run(options); diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index 342d4717511..d29147c1823 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -153,7 +153,7 @@ describe("SettingsComponent", () => { it("pin enabled when RemoveUnlockWithPin policy is not set", async () => { // @ts-strict-ignore - policyService.get$.mockReturnValue(of(null)); + policyService.policiesByType$.mockReturnValue(of([null])); await component.ngOnInit(); @@ -164,7 +164,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = false; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); @@ -175,7 +175,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); @@ -184,7 +184,7 @@ describe("SettingsComponent", () => { it("pin visible when RemoveUnlockWithPin policy is not set", async () => { // @ts-strict-ignore - policyService.get$.mockReturnValue(of(null)); + policyService.policiesByType$.mockReturnValue(of([null])); await component.ngOnInit(); fixture.detectChanges(); @@ -201,7 +201,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = false; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); fixture.detectChanges(); @@ -218,7 +218,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); pinServiceAbstraction.isPinSet.mockResolvedValue(true); await component.ngOnInit(); @@ -236,7 +236,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); await component.ngOnInit(); fixture.detectChanges(); @@ -255,7 +255,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = false; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); i18nService.t.mockImplementation((id: string) => { if (id === "requirePasswordOnStart") { @@ -290,7 +290,7 @@ describe("SettingsComponent", () => { const policy = new Policy(); policy.type = PolicyType.RemoveUnlockWithPin; policy.enabled = true; - policyService.get$.mockReturnValue(of(policy)); + policyService.policiesByType$.mockReturnValue(of([policy])); platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); i18nService.t.mockImplementation((id: string) => { if (id === "requirePasswordOnStart") { diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index cec0d98ccac..20b6d509f4d 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -17,8 +17,10 @@ import { import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DeviceType } from "@bitwarden/common/enums"; @@ -235,7 +237,12 @@ export class SettingsComponent implements OnInit, OnDestroy { ); // Load timeout policy - this.vaultTimeoutPolicyCallout = this.policyService.get$(PolicyType.MaximumVaultTimeout).pipe( + this.vaultTimeoutPolicyCallout = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, filter((policy) => policy != null), map((policy) => { let timeout; @@ -259,7 +266,12 @@ export class SettingsComponent implements OnInit, OnDestroy { // Load initial values this.userHasPinSet = await this.pinService.isPinSet(activeAccount.id); - this.pinEnabled$ = this.policyService.get$(PolicyType.RemoveUnlockWithPin).pipe( + this.pinEnabled$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.RemoveUnlockWithPin, userId), + ), + getFirstPolicy, map((policy) => { return policy == null || !policy.enabled; }), diff --git a/apps/desktop/src/services/encrypted-message-handler.service.ts b/apps/desktop/src/services/encrypted-message-handler.service.ts index a8a1e738644..591ff6fa8cf 100644 --- a/apps/desktop/src/services/encrypted-message-handler.service.ts +++ b/apps/desktop/src/services/encrypted-message-handler.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -144,10 +144,14 @@ export class EncryptedMessageHandlerService { const credentialCreatePayload = payload as CredentialCreatePayload; - if ( - credentialCreatePayload.name == null || - (await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership)) - ) { + const policyApplies$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + ); + + if (credentialCreatePayload.name == null || (await firstValueFrom(policyApplies$))) { return { status: "failure" }; } diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 73973e7ffde..43d8f910d0f 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -5,6 +5,7 @@ import { firstValueFrom, Subject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -48,6 +49,7 @@ export class VaultFilterComponent protected billingApiService: BillingApiServiceAbstraction, protected dialogService: DialogService, protected configService: ConfigService, + protected accountService: AccountService, ) { super( vaultFilterService, @@ -58,6 +60,7 @@ export class VaultFilterComponent billingApiService, dialogService, configService, + accountService, ); } 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 0f3ca55da6f..daa1077a0f3 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 @@ -118,7 +118,10 @@ export class OrganizationLayoutComponent implements OnInit { ), ); - this.hideNewOrgButton$ = this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg); + this.hideNewOrgButton$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)), + ); const provider$ = this.organization$.pipe( switchMap((organization) => this.providerService.get$(organization.providerId)), diff --git a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts index 1b08d081823..84cb541e9f4 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/reset-password.component.ts @@ -3,11 +3,13 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, switchMap, takeUntil } from "rxjs"; import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component"; 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 { 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -81,12 +83,16 @@ export class ResetPasswordComponent implements OnInit, OnDestroy { private toastService: ToastService, private formBuilder: FormBuilder, private dialogRef: DialogRef, + private accountService: AccountService, ) {} async ngOnInit() { - this.policyService - .masterPasswordPolicyOptions$() - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), + takeUntil(this.destroy$), + ) .subscribe( (enforcedPasswordPolicyOptions) => (this.enforcedPolicyOptions = enforcedPasswordPolicyOptions), 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 0bfdde8fc97..a64247339a5 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 @@ -43,6 +43,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -168,15 +169,18 @@ export class MembersComponent extends BaseMembersComponent this.canUseSecretsManager$ = organization$.pipe(map((org) => org.useSecretsManager)); - const policies$ = organization$.pipe( - switchMap((organization) => { + const policies$ = combineLatest([ + this.accountService.activeAccount$.pipe(getUserId), + organization$, + ]).pipe( + switchMap(([userId, organization]) => { if (organization.isProviderUser) { return from(this.policyApiService.getPolicies(organization.id)).pipe( map((response) => Policy.fromListResponse(response)), ); } - return this.policyService.policies$; + return this.policyService.policies$(userId); }), ); diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts index 5d5770e2325..57ea5e8ee05 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.spec.ts @@ -8,11 +8,15 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; // FIXME: remove `src` and fix import @@ -38,6 +42,8 @@ describe("WebLoginComponentService", () => { let passwordGenerationService: MockProxy; let platformUtilsService: MockProxy; let ssoLoginService: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; beforeEach(() => { acceptOrganizationInviteService = mock(); @@ -50,6 +56,7 @@ describe("WebLoginComponentService", () => { passwordGenerationService = mock(); platformUtilsService = mock(); ssoLoginService = mock(); + accountService = mockAccountServiceWith(mockUserId); TestBed.configureTestingModule({ providers: [ @@ -65,6 +72,7 @@ describe("WebLoginComponentService", () => { { provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService }, { provide: PlatformUtilsService, useValue: platformUtilsService }, { provide: SsoLoginServiceAbstraction, useValue: ssoLoginService }, + { provide: AccountService, useValue: accountService }, ], }); service = TestBed.inject(WebLoginComponentService); diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index 29f2f237ec1..8d4c3bd84f0 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, switchMap } from "rxjs"; import { DefaultLoginComponentService, @@ -12,7 +12,9 @@ import { import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -39,6 +41,7 @@ export class WebLoginComponentService platformUtilsService: PlatformUtilsService, ssoLoginService: SsoLoginServiceAbstraction, private router: Router, + private accountService: AccountService, ) { super( cryptoFunctionService, @@ -93,7 +96,10 @@ export class WebLoginComponentService resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; const enforcedPasswordPolicyOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(policies), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), + ), ); return { diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts index 7409fa3bea3..48b74dc5e2e 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.spec.ts @@ -10,9 +10,12 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management"; @@ -30,6 +33,8 @@ describe("WebRegistrationFinishService", () => { let policyApiService: MockProxy; let logService: MockProxy; let policyService: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; beforeEach(() => { keyService = mock(); @@ -38,6 +43,7 @@ describe("WebRegistrationFinishService", () => { policyApiService = mock(); logService = mock(); policyService = mock(); + accountService = mockAccountServiceWith(mockUserId); service = new WebRegistrationFinishService( keyService, @@ -46,6 +52,7 @@ describe("WebRegistrationFinishService", () => { policyApiService, logService, policyService, + accountService, ); }); diff --git a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts index 569b417a3cb..3d99b3b6712 100644 --- a/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts +++ b/apps/web/src/app/auth/core/services/registration/web-registration-finish.service.ts @@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string"; @@ -30,6 +31,7 @@ export class WebRegistrationFinishService private policyApiService: PolicyApiServiceAbstraction, private logService: LogService, private policyService: PolicyService, + private accountService: AccountService, ) { super(keyService, accountApiService); } @@ -68,7 +70,7 @@ export class WebRegistrationFinishService } const masterPasswordPolicyOpts: MasterPasswordPolicyOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(policies), + this.policyService.masterPasswordPolicyOptions$(null, policies), ); return masterPasswordPolicyOpts; diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts index 68f439d34a4..6eee68585db 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.ts @@ -3,11 +3,12 @@ import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, OnDestroy, OnInit, Inject, Input } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { takeUntil } from "rxjs"; +import { switchMap, takeUntil } from "rxjs"; import { ChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -79,9 +80,12 @@ export class EmergencyAccessTakeoverComponent const policies = await this.emergencyAccessService.getGrantorPolicies( this.params.emergencyAccessId, ); - this.policyService - .masterPasswordPolicyOptions$(policies) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)), + takeUntil(this.destroy$), + ) .subscribe((enforcedPolicyOptions) => (this.enforcedPolicyOptions = enforcedPolicyOptions)); } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index 83c9ff23f3c..582a9412182 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -25,6 +25,7 @@ import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response"; import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response"; import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; import { AuthResponse } from "@bitwarden/common/auth/types/auth-response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; @@ -109,13 +110,17 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { this.providers.sort((a: any, b: any) => a.sort - b.sort); - this.policyService - .policyAppliesToActiveUser$(PolicyType.TwoFactorAuthentication) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.TwoFactorAuthentication, userId), + ), + takeUntil(this.destroy$), + ) .subscribe((policyAppliesToActiveUser) => { this.twoFactorAuthPolicyAppliesToActiveUser = policyAppliesToActiveUser; }); - await this.load(); } diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts index c84800aefd4..13c7993768c 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.ts @@ -1,10 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, switchMap, takeUntil } from "rxjs"; 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 { DialogService } from "@bitwarden/components"; import { WebauthnLoginAdminService } from "../../core"; @@ -35,6 +37,7 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { private webauthnService: WebauthnLoginAdminService, private dialogService: DialogService, private policyService: PolicyService, + private accountService: AccountService, ) {} @HostBinding("attr.aria-busy") @@ -57,9 +60,14 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy { requireSsoPolicyEnabled = false; ngOnInit(): void { - this.policyService - .policyAppliesToActiveUser$(PolicyType.RequireSso) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.RequireSso, userId), + ), + takeUntil(this.destroy$), + ) .subscribe((enabled) => { this.requireSsoPolicyEnabled = enabled; }); 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 536e7b7020e..dc748e9ee41 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 @@ -13,7 +13,7 @@ import { } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, map, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -28,6 +28,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request"; import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction, BillingInformation, @@ -265,9 +266,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } this.upgradeFlowPrefillForm(); - this.policyService - .policyAppliesToActiveUser$(PolicyType.SingleOrg) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ), + takeUntil(this.destroy$), + ) .subscribe((policyAppliesToActiveUser) => { this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser; }); 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 84f2467b1bd..fc7d6793a85 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -12,7 +12,7 @@ import { import { FormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; import { Subject, firstValueFrom, takeUntil } from "rxjs"; -import { debounceTime, map } from "rxjs/operators"; +import { debounceTime, map, switchMap } from "rxjs/operators"; import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -31,6 +31,7 @@ import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/mode import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-organization-create.request"; import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction"; import { PaymentMethodType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; @@ -240,9 +241,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formGroup.controls.billingEmail.addValidators(Validators.required); } - this.policyService - .policyAppliesToActiveUser$(PolicyType.SingleOrg) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ), + takeUntil(this.destroy$), + ) .subscribe((policyAppliesToActiveUser) => { this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser; }); diff --git a/apps/web/src/app/billing/services/free-families-policy.service.ts b/apps/web/src/app/billing/services/free-families-policy.service.ts index da569ffc993..8d4e89d40a0 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.ts @@ -96,7 +96,7 @@ export class FreeFamiliesPolicyService { return this.accountService.activeAccount$.pipe( getUserId, switchMap((userId) => - this.policyService.getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), + this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), ), map((policies) => ({ isFreeFamilyPolicyEnabled: policies.some( diff --git a/apps/web/src/app/billing/settings/sponsored-families.component.ts b/apps/web/src/app/billing/settings/sponsored-families.component.ts index c35fd3a2e61..cee08bae8cd 100644 --- a/apps/web/src/app/billing/settings/sponsored-families.component.ts +++ b/apps/web/src/app/billing/settings/sponsored-families.component.ts @@ -89,7 +89,7 @@ export class SponsoredFamiliesComponent implements OnInit, OnDestroy { this.availableSponsorshipOrgs$ = combineLatest([ this.organizationService.organizations$(userId), - this.policyService.getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), + this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), ]).pipe( map(([organizations, policies]) => organizations diff --git a/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts b/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts index e613b862922..c4e22c0a7e1 100644 --- a/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts +++ b/apps/web/src/app/billing/settings/sponsoring-org-row.component.ts @@ -10,7 +10,6 @@ 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; -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 { DialogService, ToastService } from "@bitwarden/components"; @@ -36,7 +35,6 @@ export class SponsoringOrgRowComponent implements OnInit { private logService: LogService, private dialogService: DialogService, private toastService: ToastService, - private configService: ConfigService, private policyService: PolicyService, private accountService: AccountService, ) {} @@ -54,7 +52,7 @@ export class SponsoringOrgRowComponent implements OnInit { this.isFreeFamilyPolicyEnabled$ = this.accountService.activeAccount$.pipe( getUserId, switchMap((userId) => - this.policyService.getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), + this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId), ), map( (policies) => diff --git a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts index 873ceea2ada..27ce4dc9f5d 100644 --- a/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts +++ b/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts @@ -4,7 +4,7 @@ import { StepperSelectionEvent } from "@angular/cdk/stepper"; import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom, Subject, takeUntil } from "rxjs"; +import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; import { PasswordInputResult, RegistrationFinishService } from "@bitwarden/auth/angular"; import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common"; @@ -12,6 +12,8 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs 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 { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationBillingServiceAbstraction as OrganizationBillingService, OrganizationInformation, @@ -106,6 +108,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { private validationService: ValidationService, private loginStrategyService: LoginStrategyServiceAbstraction, private configService: ConfigService, + private accountService: AccountService, ) {} async ngOnInit(): Promise { @@ -173,9 +176,12 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { } if (policies !== null) { - this.policyService - .masterPasswordPolicyOptions$(policies) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)), + takeUntil(this.destroy$), + ) .subscribe((enforcedPasswordPolicyOptions) => { this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; }); diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index cc1e481d39b..cc9024490d6 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -256,6 +256,7 @@ const safeProviders: SafeProvider[] = [ PolicyApiServiceAbstraction, LogService, PolicyService, + AccountService, ], }), safeProvider({ @@ -311,6 +312,7 @@ const safeProviders: SafeProvider[] = [ PlatformUtilsService, SsoLoginServiceAbstraction, Router, + AccountService, ], }), safeProvider({ diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index aedad9b26ea..d4a9c866fba 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -1,10 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { switchMap } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DeviceType, EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EventResponse } from "@bitwarden/common/models/response/event.response"; @@ -19,10 +22,16 @@ export class EventService { private i18nService: I18nService, policyService: PolicyService, private configService: ConfigService, + private accountService: AccountService, ) { - policyService.policies$.subscribe((policies) => { - this.policies = policies; - }); + accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => policyService.policies$(userId)), + ) + .subscribe((policies) => { + this.policies = policies; + }); } getDefaultDateFilters() { diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index a90f1d18afd..4d4e0c3d711 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -2,11 +2,23 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { concatMap, filter, firstValueFrom, map, Observable, Subject, takeUntil, tap } from "rxjs"; +import { + concatMap, + filter, + firstValueFrom, + map, + Observable, + Subject, + switchMap, + takeUntil, + tap, +} from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { VaultTimeout, @@ -100,7 +112,12 @@ export class PreferencesComponent implements OnInit, OnDestroy { this.availableVaultTimeoutActions$ = this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(); - this.vaultTimeoutPolicyCallout = this.policyService.get$(PolicyType.MaximumVaultTimeout).pipe( + this.vaultTimeoutPolicyCallout = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, filter((policy) => policy != null), map((policy) => { let timeout; diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts index 37e3aca6cd5..4b9791c61bf 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts @@ -75,8 +75,10 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { ) {} async ngOnInit() { - const resetPasswordPolicies$ = this.policyService.policies$.pipe( - map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)), + const resetPasswordPolicies$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), + map((policies) => policies.filter((p) => p.type == PolicyType.ResetPassword)), ); const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 786c5de740e..0a168157705 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -6,6 +6,9 @@ import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -101,6 +104,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected billingApiService: BillingApiServiceAbstraction, protected dialogService: DialogService, protected configService: ConfigService, + protected accountService: AccountService, ) {} async ngOnInit(): Promise { @@ -110,10 +114,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.isLoaded = true; // Without refactoring the entire component, we need to manually update the organization filter whenever the policies update - merge( - this.policyService.get$(PolicyType.SingleOrg), - this.policyService.get$(PolicyType.PersonalOwnership), - ) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + merge( + this.policyService.policiesByType$(PolicyType.SingleOrg, userId).pipe(getFirstPolicy), + this.policyService + .policiesByType$(PolicyType.PersonalOwnership, userId) + .pipe(getFirstPolicy), + ), + ), + ) .pipe( switchMap(() => this.addOrganizationFilter()), takeUntil(this.destroy$), @@ -190,9 +202,22 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } protected async addOrganizationFilter(): Promise { - const singleOrgPolicy = await this.policyService.policyAppliesToUser(PolicyType.SingleOrg); - const personalVaultPolicy = await this.policyService.policyAppliesToUser( - PolicyType.PersonalOwnership, + const singleOrgPolicy = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ), + ), + ); + + const personalVaultPolicy = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + ), ); const addAction = !singleOrgPolicy diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 15f5e1cd876..f56931fa987 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -65,11 +65,11 @@ describe("vault filter service", () => { organizationService.memberOrganizations$.mockReturnValue(organizations); folderService.folderViews$.mockReturnValue(folderViews); collectionService.decryptedCollections$ = collectionViews; - policyService.policyAppliesToActiveUser$ - .calledWith(PolicyType.PersonalOwnership) + policyService.policyAppliesToUser$ + .calledWith(PolicyType.PersonalOwnership, mockUserId) .mockReturnValue(personalOwnershipPolicy); - policyService.policyAppliesToActiveUser$ - .calledWith(PolicyType.SingleOrg) + policyService.policyAppliesToUser$ + .calledWith(PolicyType.SingleOrg, mockUserId) .mockReturnValue(singleOrgPolicy); cipherService.cipherViews$.mockReturnValue(cipherViews); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index 67c369fa0f2..f3e4441af9f 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -55,8 +55,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { organizationTree$: Observable> = combineLatest([ this.memberOrganizations$, - this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg), - this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + this.activeUserId$.pipe( + switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)), + ), + this.activeUserId$.pipe( + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + ), ]).pipe( switchMap(([orgs, singleOrgPolicy, personalOwnershipPolicy]) => this.buildOrganizationTree(orgs, singleOrgPolicy, personalOwnershipPolicy), diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts index 1a767bc8964..5ec045f7be8 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.spec.ts @@ -142,13 +142,13 @@ describe("VaultOnboardingComponent", () => { }); describe("individualVaultPolicyCheck", () => { - it("should set isIndividualPolicyVault to true", async () => { + it("should set isIndividualPolicyVault to true", () => { individualVaultPolicyCheckSpy.mockRestore(); const spy = jest - .spyOn((component as any).policyService, "policyAppliesToActiveUser$") + .spyOn((component as any).policyService, "policyAppliesToUser$") .mockReturnValue(of(true)); - await component.individualVaultPolicyCheck(); + component.individualVaultPolicyCheck(); fixture.detectChanges(); expect(spy).toHaveBeenCalled(); }); diff --git a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts index dc4a014073a..b3a4b324d30 100644 --- a/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-onboarding/vault-onboarding.component.ts @@ -11,7 +11,7 @@ import { SimpleChanges, OnChanges, } from "@angular/core"; -import { Subject, takeUntil, Observable, firstValueFrom, fromEvent } from "rxjs"; +import { Subject, takeUntil, Observable, firstValueFrom, fromEvent, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -20,7 +20,6 @@ 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; @@ -67,7 +66,6 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { protected policyService: PolicyService, private apiService: ApiService, private vaultOnboardingService: VaultOnboardingServiceAbstraction, - private configService: ConfigService, private accountService: AccountService, ) {} @@ -165,9 +163,14 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy { } individualVaultPolicyCheck() { - this.policyService - .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + takeUntil(this.destroy$), + ) .subscribe((data) => { this.isIndividualPolicyVault = data; }); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index 9ee13bf077a..0934a6deb95 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -38,7 +38,7 @@ describe("AdminConsoleCipherFormConfigService", () => { status: OrganizationUserStatusType.Confirmed, userId: "UserId", }; - const policyAppliesToActiveUser$ = new BehaviorSubject(true); + const policyAppliesToUser$ = new BehaviorSubject(true); const collection = { id: "12345-5555", organizationId: "234534-34334", @@ -75,7 +75,7 @@ describe("AdminConsoleCipherFormConfigService", () => { }, { provide: PolicyService, - useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ }, + useValue: { policyAppliesToUser$: () => policyAppliesToUser$ }, }, { provide: RoutedVaultFilterService, @@ -129,13 +129,13 @@ describe("AdminConsoleCipherFormConfigService", () => { }); it("sets `allowPersonalOwnership`", async () => { - policyAppliesToActiveUser$.next(true); + policyAppliesToUser$.next(true); let result = await adminConsoleConfigService.buildConfig("clone", cipherId); expect(result.allowPersonalOwnership).toBe(false); - policyAppliesToActiveUser$.next(false); + policyAppliesToUser$.next(false); result = await adminConsoleConfigService.buildConfig("clone", cipherId); @@ -143,7 +143,7 @@ describe("AdminConsoleCipherFormConfigService", () => { }); it("disables personal ownership when not cloning", async () => { - policyAppliesToActiveUser$.next(false); + policyAppliesToUser$.next(false); let result = await adminConsoleConfigService.buildConfig("add", cipherId); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 19259ba4033..dd9cef91a54 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli import { OrganizationUserStatusType, 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 { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -30,9 +31,13 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ private apiService: ApiService = inject(ApiService); private accountService: AccountService = inject(AccountService); - private allowPersonalOwnership$ = this.policyService - .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) - .pipe(map((p) => !p)); + private allowPersonalOwnership$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + map((p) => !p), + ); private organizationId$ = this.routedVaultFilterService.filter$.pipe( map((filter) => filter.organizationId), diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts index 70823d61c39..d346b7ba9ba 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts @@ -8,6 +8,7 @@ import { map, Observable, Subject, + switchMap, take, takeUntil, withLatestFrom, @@ -18,6 +19,8 @@ import { OrgDomainServiceAbstraction } from "@bitwarden/common/admin-console/abs import { OrganizationDomainResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain.response"; 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 { HttpStatusCode } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -55,6 +58,7 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { private toastService: ToastService, private configService: ConfigService, private policyService: PolicyService, + private accountService: AccountService, ) { this.accountDeprovisioningEnabled$ = this.configService.getFeatureFlag$( FeatureFlag.AccountDeprovisioning, @@ -83,7 +87,9 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { if (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) { const singleOrgPolicy = await firstValueFrom( - this.policyService.policies$.pipe( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policies$(userId)), map((policies) => policies.find( (p) => p.type === PolicyType.SingleOrg && p.organizationId === this.organizationId, diff --git a/libs/angular/src/auth/components/change-password.component.ts b/libs/angular/src/auth/components/change-password.component.ts index 2582d6a7103..3b186a7fd2e 100644 --- a/libs/angular/src/auth/components/change-password.component.ts +++ b/libs/angular/src/auth/components/change-password.component.ts @@ -1,11 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, map, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -52,9 +53,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { this.email = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.email)), ); - this.policyService - .masterPasswordPolicyOptions$() - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), + takeUntil(this.destroy$), + ) .subscribe( (enforcedPasswordPolicyOptions) => (this.enforcedPolicyOptions ??= enforcedPasswordPolicyOptions), diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 4672788eb81..3a28f28caaf 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -75,12 +75,13 @@ import { OrganizationApiService } from "@bitwarden/common/admin-console/services import { OrgDomainApiService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain-api.service"; import { OrgDomainService } from "@bitwarden/common/admin-console/services/organization-domain/org-domain.service"; import { DefaultOrganizationManagementPreferencesService } from "@bitwarden/common/admin-console/services/organization-management-preferences/default-organization-management-preferences.service"; +import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; -import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; import { ProviderApiService } from "@bitwarden/common/admin-console/services/provider/provider-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service"; import { + AccountService, AccountService as AccountServiceAbstraction, InternalAccountService, } from "@bitwarden/common/auth/abstractions/account.service"; @@ -947,7 +948,7 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: InternalPolicyService, - useClass: PolicyService, + useClass: DefaultPolicyService, deps: [StateProvider, OrganizationServiceAbstraction], }), safeProvider({ @@ -957,7 +958,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: PolicyApiServiceAbstraction, useClass: PolicyApiService, - deps: [InternalPolicyService, ApiServiceAbstraction], + deps: [InternalPolicyService, ApiServiceAbstraction, AccountService], }), safeProvider({ provide: InternalMasterPasswordServiceAbstraction, @@ -1259,7 +1260,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: AutofillSettingsServiceAbstraction, useClass: AutofillSettingsService, - deps: [StateProvider, PolicyServiceAbstraction], + deps: [StateProvider, PolicyServiceAbstraction, AccountService], }), safeProvider({ provide: BadgeSettingsServiceAbstraction, diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 4f7d4b6b600..7e6180e5849 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -155,9 +155,14 @@ export class AddEditComponent implements OnInit, OnDestroy { } async ngOnInit() { - this.policyService - .policyAppliesToActiveUser$(PolicyType.DisableSend) - .pipe(takeUntil(this.destroy$)) + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId), + ), + takeUntil(this.destroy$), + ) .subscribe((policyAppliesToActiveUser) => { this.disableSend = policyAppliesToActiveUser; if (this.disableSend) { @@ -168,7 +173,7 @@ export class AddEditComponent implements OnInit, OnDestroy { this.accountService.activeAccount$ .pipe( getUserId, - switchMap((userId) => this.policyService.getAll$(PolicyType.SendOptions, userId)), + switchMap((userId) => this.policyService.policiesByType$(PolicyType.SendOptions, userId)), map((policies) => policies?.some((p) => p.data.disableHideEmail)), takeUntil(this.destroy$), ) diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index 738960fc628..5dbf3686b7d 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -9,6 +9,7 @@ import { from, switchMap, takeUntil, + combineLatest, } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -85,18 +86,23 @@ export class SendComponent implements OnInit, OnDestroy { ) {} async ngOnInit() { - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - - this.policyService - .policyAppliesToActiveUser$(PolicyType.DisableSend) - .pipe(takeUntil(this.destroy$)) - .subscribe((policyAppliesToActiveUser) => { - this.disableSend = policyAppliesToActiveUser; + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId), + ), + takeUntil(this.destroy$), + ) + .subscribe((policyAppliesToUser) => { + this.disableSend = policyAppliesToUser; }); - this._searchText$ + combineLatest([this._searchText$, this.accountService.activeAccount$.pipe(getUserId)]) .pipe( - switchMap((searchText) => from(this.searchService.isSearchable(userId, searchText))), + switchMap(([searchText, userId]) => + from(this.searchService.isSearchable(userId, searchText)), + ), takeUntil(this.destroy$), ) .subscribe((isSearchable) => { diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index eed90c2ba70..2393863bb5f 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { DatePipe } from "@angular/common"; import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { concatMap, firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, map, Observable, Subject, switchMap, takeUntil } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -193,9 +193,12 @@ export class AddEditComponent implements OnInit, OnDestroy { } async ngOnInit() { - this.policyService - .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + this.accountService.activeAccount$ .pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), concatMap(async (policyAppliesToActiveUser) => { this.personalOwnershipPolicyAppliesToActiveUser = policyAppliesToActiveUser; await this.init(); diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 01fa3384b82..6c3ac21b162 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -112,13 +112,23 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti async checkForSingleOrganizationPolicy(): Promise { return await firstValueFrom( - this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ), + ), ); } async checkForPersonalOwnershipPolicy(): Promise { return await firstValueFrom( - this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + ), ); } diff --git a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.spec.ts b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.spec.ts index d510d671d69..05bb97dde26 100644 --- a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.spec.ts +++ b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.spec.ts @@ -2,25 +2,32 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { BehaviorSubject } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { VaultTimeoutInputComponent } from "./vault-timeout-input.component"; describe("VaultTimeoutInputComponent", () => { let component: VaultTimeoutInputComponent; let fixture: ComponentFixture; - const get$ = jest.fn().mockReturnValue(new BehaviorSubject({})); + const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({})); const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([])); + const mockUserId = Utils.newGuid() as UserId; + const accountService = mockAccountServiceWith(mockUserId); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [VaultTimeoutInputComponent], providers: [ - { provide: PolicyService, useValue: { get$ } }, + { provide: PolicyService, useValue: { policiesByType$ } }, + { provide: AccountService, useValue: accountService }, { provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], diff --git a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts index 91af1e8adbe..82bc53bb147 100644 --- a/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts +++ b/libs/auth/src/angular/vault-timeout-input/vault-timeout-input.component.ts @@ -14,12 +14,15 @@ import { ValidationErrors, Validator, } from "@angular/forms"; -import { filter, map, Observable, Subject, takeUntil } from "rxjs"; +import { filter, map, Observable, Subject, switchMap, takeUntil } 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 { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { VaultTimeout, VaultTimeoutAction, @@ -123,12 +126,17 @@ export class VaultTimeoutInputComponent private policyService: PolicyService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private i18nService: I18nService, + private accountService: AccountService, ) {} async ngOnInit() { - this.policyService - .get$(PolicyType.MaximumVaultTimeout) + this.accountService.activeAccount$ .pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, filter((policy) => policy != null), takeUntil(this.destroy$), ) @@ -136,7 +144,6 @@ export class VaultTimeoutInputComponent this.vaultTimeoutPolicy = policy; this.applyVaultTimeoutPolicy(); }); - this.form.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((value: VaultTimeoutFormValue) => { 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 18cc13b29c9..4db0fc16750 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 @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ListResponse } from "../../../models/response/list.response"; import { PolicyType } from "../../enums"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; @@ -7,19 +5,23 @@ import { Policy } from "../../models/domain/policy"; import { PolicyRequest } from "../../models/request/policy.request"; import { PolicyResponse } from "../../models/response/policy.response"; -export class PolicyApiServiceAbstraction { - getPolicy: (organizationId: string, type: PolicyType) => Promise; - getPolicies: (organizationId: string) => Promise>; +export abstract class PolicyApiServiceAbstraction { + abstract getPolicy: (organizationId: string, type: PolicyType) => Promise; + abstract getPolicies: (organizationId: string) => Promise>; - getPoliciesByToken: ( + abstract getPoliciesByToken: ( organizationId: string, token: string, email: string, organizationUserId: string, ) => Promise; - getMasterPasswordPolicyOptsForOrgUser: ( + abstract getMasterPasswordPolicyOptsForOrgUser: ( orgId: string, ) => Promise; - putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise; + abstract putPolicy: ( + organizationId: string, + type: PolicyType, + request: PolicyRequest, + ) => Promise; } diff --git a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts index 4280756326c..68f9843c5bd 100644 --- a/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/policy/policy.service.abstraction.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { UserId } from "../../../types/guid"; @@ -11,43 +9,27 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p export abstract class PolicyService { /** - * All policies for the active user from sync data. + * All policies for the provided user from sync data. * May include policies that are disabled or otherwise do not apply to the user. Be careful using this! - * Consider using {@link get$} or {@link getAll$} instead, which will only return policies that should be enforced against the user. + * Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user. */ - policies$: Observable; + abstract policies$: (userId: UserId) => Observable; /** - * @returns the first {@link Policy} found that applies to the active user. + * @returns all {@link Policy} objects of a given type that apply to the specified user. * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). * @param policyType the {@link PolicyType} to search for - * @see {@link getAll$} if you need all policies of a given type + * @param userId the {@link UserId} to search against */ - get$: (policyType: PolicyType) => Observable; + abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable; /** - * @returns all {@link Policy} objects of a given type that apply to the specified user (or the active user if not specified). + * @returns true if a policy of the specified type applies to the specified user, otherwise false. * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). - * @param policyType the {@link PolicyType} to search for - */ - getAll$: (policyType: PolicyType, userId: UserId) => Observable; - - /** - * All {@link Policy} objects for the specified user (from sync data). - * May include policies that are disabled or otherwise do not apply to the user. - * Consider using {@link getAll$} instead, which will only return policies that should be enforced against the user. - */ - getAll: (policyType: PolicyType) => Promise; - - /** - * @returns true if a policy of the specified type applies to the active user, otherwise false. - * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). - * This does not take into account the policy's configuration - if that is important, use {@link getAll$} to get the + * This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the * {@link Policy} objects and then filter by Policy.data. */ - policyAppliesToActiveUser$: (policyType: PolicyType) => Observable; - - policyAppliesToUser: (policyType: PolicyType) => Promise; + abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable; // Policy specific interfaces @@ -56,28 +38,31 @@ export abstract class PolicyService { * @returns a set of options which represent the minimum Master Password settings that the user must * comply with in order to comply with **all** Master Password policies. */ - masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable; + abstract masterPasswordPolicyOptions$: ( + userId: UserId, + policies?: Policy[], + ) => Observable; /** * Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user. */ - evaluateMasterPassword: ( + abstract evaluateMasterPassword: ( passwordStrength: number, newPassword: string, enforcedPolicyOptions?: MasterPasswordPolicyOptions, ) => boolean; /** - * @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy + * @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy * is enabled */ - getResetPasswordPolicyOptions: ( + abstract getResetPasswordPolicyOptions: ( policies: Policy[], orgId: string, ) => [ResetPasswordPolicyOptions, boolean]; } export abstract class InternalPolicyService extends PolicyService { - upsert: (policy: PolicyData) => Promise; - replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise; + abstract upsert: (policy: PolicyData, userId: UserId) => Promise; + abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise; } diff --git a/libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts b/libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts deleted file mode 100644 index 1e96d6b8d00..00000000000 --- a/libs/common/src/admin-console/abstractions/policy/vnext-policy.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Observable } from "rxjs"; - -import { UserId } from "../../../types/guid"; -import { PolicyType } from "../../enums"; -import { PolicyData } from "../../models/data/policy.data"; -import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; -import { Policy } from "../../models/domain/policy"; -import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; - -export abstract class vNextPolicyService { - /** - * All policies for the provided user from sync data. - * May include policies that are disabled or otherwise do not apply to the user. Be careful using this! - * Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user. - */ - abstract policies$: (userId: UserId) => Observable; - - /** - * @returns all {@link Policy} objects of a given type that apply to the specified user. - * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). - * @param policyType the {@link PolicyType} to search for - * @param userId the {@link UserId} to search against - */ - abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable; - - /** - * @returns true if a policy of the specified type applies to the specified user, otherwise false. - * A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner). - * This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the - * {@link Policy} objects and then filter by Policy.data. - */ - abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable; - - // Policy specific interfaces - - /** - * Combines all Master Password policies that apply to the user. - * @returns a set of options which represent the minimum Master Password settings that the user must - * comply with in order to comply with **all** Master Password policies. - */ - abstract masterPasswordPolicyOptions$: ( - userId: UserId, - policies?: Policy[], - ) => Observable; - - /** - * Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user. - */ - abstract evaluateMasterPassword: ( - passwordStrength: number, - newPassword: string, - enforcedPolicyOptions?: MasterPasswordPolicyOptions, - ) => boolean; - - /** - * @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy - * is enabled - */ - abstract getResetPasswordPolicyOptions: ( - policies: Policy[], - orgId: string, - ) => [ResetPasswordPolicyOptions, boolean]; -} - -export abstract class vNextInternalPolicyService extends vNextPolicyService { - abstract upsert: (policy: PolicyData, userId: UserId) => Promise; - abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise; -} diff --git a/libs/common/src/admin-console/services/policy/default-vnext-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts similarity index 98% rename from libs/common/src/admin-console/services/policy/default-vnext-policy.service.spec.ts rename to libs/common/src/admin-console/services/policy/default-policy.service.spec.ts index f58e1d27ee6..7787bdbc943 100644 --- a/libs/common/src/admin-console/services/policy/default-vnext-policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts @@ -15,11 +15,11 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai import { Organization } from "../../../admin-console/models/domain/organization"; import { Policy } from "../../../admin-console/models/domain/policy"; import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options"; -import { POLICIES } from "../../../admin-console/services/policy/policy.service"; import { PolicyId, UserId } from "../../../types/guid"; import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; -import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service"; +import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service"; +import { POLICIES } from "./policy-state"; describe("PolicyService", () => { const userId = "userId" as UserId; @@ -27,7 +27,7 @@ describe("PolicyService", () => { let organizationService: MockProxy; let singleUserState: FakeSingleUserState>; - let policyService: DefaultvNextPolicyService; + let policyService: DefaultPolicyService; beforeEach(() => { const accountService = mockAccountServiceWith(userId); @@ -59,7 +59,7 @@ describe("PolicyService", () => { organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$); - policyService = new DefaultvNextPolicyService(stateProvider, organizationService); + policyService = new DefaultPolicyService(stateProvider, organizationService); }); it("upsert", async () => { diff --git a/libs/common/src/admin-console/services/policy/default-vnext-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts similarity index 95% rename from libs/common/src/admin-console/services/policy/default-vnext-policy.service.ts rename to libs/common/src/admin-console/services/policy/default-policy.service.ts index bc56638a987..1158d29d737 100644 --- a/libs/common/src/admin-console/services/policy/default-vnext-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -3,7 +3,7 @@ import { combineLatest, map, Observable, of } from "rxjs"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; -import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service"; +import { PolicyService } from "../../abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType, PolicyType } from "../../enums"; import { PolicyData } from "../../models/data/policy.data"; import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; @@ -11,7 +11,7 @@ import { Organization } from "../../models/domain/organization"; import { Policy } from "../../models/domain/policy"; import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; -import { POLICIES } from "./vnext-policy-state"; +import { POLICIES } from "./policy-state"; export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) { return Object.values(policiesMap || {}).map((f) => new Policy(f)); @@ -21,7 +21,7 @@ export const getFirstPolicy = map((policies) => { return policies.at(0) ?? undefined; }); -export class DefaultvNextPolicyService implements vNextPolicyService { +export class DefaultPolicyService implements PolicyService { constructor( private stateProvider: StateProvider, private organizationService: OrganizationService, @@ -89,7 +89,7 @@ export class DefaultvNextPolicyService implements vNextPolicyService { const policies$ = policies ? of(policies) : this.policies$(userId); return policies$.pipe( map((obsPolicies) => { - const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions(); + let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined; const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? []; @@ -102,6 +102,10 @@ export class DefaultvNextPolicyService implements vNextPolicyService { return; } + if (!enforcedOptions) { + enforcedOptions = new MasterPasswordPolicyOptions(); + } + if ( currentPolicy.data.minComplexity != null && currentPolicy.data.minComplexity > enforcedOptions.minComplexity 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 086bbea1d21..8f9854f49c4 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 @@ -1,6 +1,8 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map, switchMap } from "rxjs"; import { ApiService } from "../../../abstractions/api.service"; +import { AccountService } from "../../../auth/abstractions/account.service"; +import { getUserId } from "../../../auth/services/account.service"; import { HttpStatusCode } from "../../../enums"; import { ErrorResponse } from "../../../models/response/error.response"; import { ListResponse } from "../../../models/response/list.response"; @@ -18,6 +20,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction { constructor( private policyService: InternalPolicyService, private apiService: ApiService, + private accountService: AccountService, ) {} async getPolicy(organizationId: string, type: PolicyType): Promise { @@ -93,8 +96,14 @@ export class PolicyApiService implements PolicyApiServiceAbstraction { return null; } - return await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$([masterPasswordPolicy]), + return firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.masterPasswordPolicyOptions$(userId, [masterPasswordPolicy]), + ), + map((policy) => policy ?? null), + ), ); } catch (error) { // If policy not found, return null @@ -114,8 +123,9 @@ export class PolicyApiService implements PolicyApiServiceAbstraction { true, true, ); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); const response = new PolicyResponse(r); const data = new PolicyData(response); - await this.policyService.upsert(data); + await this.policyService.upsert(data, userId); } } diff --git a/libs/common/src/admin-console/services/policy/vnext-policy-state.ts b/libs/common/src/admin-console/services/policy/policy-state.ts similarity index 100% rename from libs/common/src/admin-console/services/policy/vnext-policy-state.ts rename to libs/common/src/admin-console/services/policy/policy-state.ts diff --git a/libs/common/src/admin-console/services/policy/policy.service.spec.ts b/libs/common/src/admin-console/services/policy/policy.service.spec.ts deleted file mode 100644 index 48979f1e31e..00000000000 --- a/libs/common/src/admin-console/services/policy/policy.service.spec.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; - -import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; -import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state"; -import { - OrganizationUserStatusType, - OrganizationUserType, - PolicyType, -} from "../../../admin-console/enums"; -import { PermissionsApi } from "../../../admin-console/models/api/permissions.api"; -import { OrganizationData } from "../../../admin-console/models/data/organization.data"; -import { PolicyData } from "../../../admin-console/models/data/policy.data"; -import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options"; -import { Organization } from "../../../admin-console/models/domain/organization"; -import { Policy } from "../../../admin-console/models/domain/policy"; -import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options"; -import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service"; -import { PolicyId, UserId } from "../../../types/guid"; -import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; - -describe("PolicyService", () => { - const userId = "userId" as UserId; - let stateProvider: FakeStateProvider; - let organizationService: MockProxy; - let activeUserState: FakeActiveUserState>; - let singleUserState: FakeSingleUserState>; - - let policyService: PolicyService; - - beforeEach(() => { - const accountService = mockAccountServiceWith(userId); - stateProvider = new FakeStateProvider(accountService); - organizationService = mock(); - - activeUserState = stateProvider.activeUser.getFake(POLICIES); - singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES); - - const organizations$ = of([ - // User - organization("org1", true, true, OrganizationUserStatusType.Confirmed, false), - // Owner - organization( - "org2", - true, - true, - OrganizationUserStatusType.Confirmed, - false, - OrganizationUserType.Owner, - ), - // Does not use policies - organization("org3", true, false, OrganizationUserStatusType.Confirmed, false), - // Another User - organization("org4", true, true, OrganizationUserStatusType.Confirmed, false), - // Another User - organization("org5", true, true, OrganizationUserStatusType.Confirmed, false), - // Can manage policies - organization("org6", true, true, OrganizationUserStatusType.Confirmed, true), - ]); - - organizationService.organizations$.mockReturnValue(organizations$); - - policyService = new PolicyService(stateProvider, organizationService); - }); - - it("upsert", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }), - ]), - ); - - await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true)); - - expect(await firstValueFrom(policyService.policies$)).toEqual([ - { - id: "1", - organizationId: "test-organization", - type: PolicyType.MaximumVaultTimeout, - enabled: true, - data: { minutes: 14 }, - }, - { - id: "99", - organizationId: "test-organization", - type: PolicyType.DisableSend, - enabled: true, - }, - ]); - }); - - it("replace", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }), - ]), - ); - - await policyService.replace( - { - "2": policyData("2", "test-organization", PolicyType.DisableSend, true), - }, - userId, - ); - - expect(await firstValueFrom(policyService.policies$)).toEqual([ - { - id: "2", - organizationId: "test-organization", - type: PolicyType.DisableSend, - enabled: true, - }, - ]); - }); - - describe("masterPasswordPolicyOptions", () => { - it("returns default policy options", async () => { - const data: any = { - minComplexity: 5, - minLength: 20, - requireUpper: true, - }; - const model = [ - new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)), - ]; - const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model)); - - expect(result).toEqual({ - minComplexity: 5, - minLength: 20, - requireLower: false, - requireNumbers: false, - requireSpecial: false, - requireUpper: true, - enforceOnLogin: false, - }); - }); - - it("returns null", async () => { - const data: any = {}; - const model = [ - new Policy( - policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data), - ), - new Policy( - policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data), - ), - ]; - - const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model)); - - expect(result).toEqual(null); - }); - - it("returns specified policy options", async () => { - const data: any = { - minLength: 14, - }; - const model = [ - new Policy( - policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data), - ), - new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)), - ]; - - const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model)); - - expect(result).toEqual({ - minComplexity: 0, - minLength: 14, - requireLower: false, - requireNumbers: false, - requireSpecial: false, - requireUpper: false, - enforceOnLogin: false, - }); - }); - }); - - describe("evaluateMasterPassword", () => { - it("false", async () => { - const enforcedPolicyOptions = new MasterPasswordPolicyOptions(); - enforcedPolicyOptions.minLength = 14; - const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions); - - expect(result).toEqual(false); - }); - - it("true", async () => { - const enforcedPolicyOptions = new MasterPasswordPolicyOptions(); - const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions); - - expect(result).toEqual(true); - }); - }); - - describe("getResetPasswordPolicyOptions", () => { - it("default", async () => { - const result = policyService.getResetPasswordPolicyOptions([], ""); - - expect(result).toEqual([new ResetPasswordPolicyOptions(), false]); - }); - - it("returns autoEnrollEnabled true", async () => { - const data: any = { - autoEnrollEnabled: true, - }; - const policies = [ - new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)), - ]; - const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3"); - - expect(result).toEqual([{ autoEnrollEnabled: true }, true]); - }); - }); - - describe("get$", () => { - it("returns the specified PolicyType", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy1", "org1", PolicyType.ActivateAutofill, true), - policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true), - policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true), - ]), - ); - - await expect( - firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)), - ).resolves.toMatchObject({ - id: "policy1", - organizationId: "org1", - type: PolicyType.ActivateAutofill, - enabled: true, - }); - await expect( - firstValueFrom(policyService.get$(PolicyType.DisablePersonalVaultExport)), - ).resolves.toMatchObject({ - id: "policy2", - organizationId: "org1", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }); - await expect( - firstValueFrom(policyService.get$(PolicyType.RemoveUnlockWithPin)), - ).resolves.toMatchObject({ - id: "policy3", - organizationId: "org1", - type: PolicyType.RemoveUnlockWithPin, - enabled: true, - }); - }); - - it("does not return disabled policies", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy1", "org1", PolicyType.ActivateAutofill, true), - policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false), - ]), - ); - - const result = await firstValueFrom( - policyService.get$(PolicyType.DisablePersonalVaultExport), - ); - - expect(result).toBeNull(); - }); - - it("does not return policies that do not apply to the user because the user's role is exempt", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy1", "org1", PolicyType.ActivateAutofill, true), - policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false), - ]), - ); - - const result = await firstValueFrom( - policyService.get$(PolicyType.DisablePersonalVaultExport), - ); - - expect(result).toBeNull(); - }); - - it.each([ - ["owners", "org2"], - ["administrators", "org6"], - ])("returns the password generator policy for %s", async (_, organization) => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy1", "org1", PolicyType.ActivateAutofill, false), - policyData("policy2", organization, PolicyType.PasswordGenerator, true), - ]), - ); - - const result = await firstValueFrom(policyService.get$(PolicyType.PasswordGenerator)); - - expect(result).toBeTruthy(); - }); - - it("does not return policies for organizations that do not use policies", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy1", "org3", PolicyType.ActivateAutofill, true), - policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true), - ]), - ); - - const result = await firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)); - - expect(result).toBeNull(); - }); - }); - - describe("getAll$", () => { - it("returns the specified PolicyTypes", async () => { - singleUserState.nextState( - arrayToRecord([ - policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true), - policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), - ]), - ); - - const result = await firstValueFrom( - policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId), - ); - - expect(result).toEqual([ - { - id: "policy1", - organizationId: "org4", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - { - id: "policy3", - organizationId: "org5", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - { - id: "policy4", - organizationId: "org1", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - ]); - }); - - it("does not return disabled policies", async () => { - singleUserState.nextState( - arrayToRecord([ - policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled - policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), - ]), - ); - - const result = await firstValueFrom( - policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId), - ); - - expect(result).toEqual([ - { - id: "policy1", - organizationId: "org4", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - { - id: "policy4", - organizationId: "org1", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - ]); - }); - - it("does not return policies that do not apply to the user because the user's role is exempt", async () => { - singleUserState.nextState( - arrayToRecord([ - policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true), - policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner - ]), - ); - - const result = await firstValueFrom( - policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId), - ); - - expect(result).toEqual([ - { - id: "policy1", - organizationId: "org4", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - { - id: "policy3", - organizationId: "org5", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - ]); - }); - - it("does not return policies for organizations that do not use policies", async () => { - singleUserState.nextState( - arrayToRecord([ - policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies - policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), - ]), - ); - - const result = await firstValueFrom( - policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId), - ); - - expect(result).toEqual([ - { - id: "policy1", - organizationId: "org4", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - { - id: "policy4", - organizationId: "org1", - type: PolicyType.DisablePersonalVaultExport, - enabled: true, - }, - ]); - }); - }); - - describe("policyAppliesToActiveUser$", () => { - it("returns true when the policyType applies to the user", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true), - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true), - policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true), - ]), - ); - - const result = await firstValueFrom( - policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport), - ); - - expect(result).toBe(true); - }); - - it("returns false when policyType is disabled", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled - ]), - ); - - const result = await firstValueFrom( - policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport), - ); - - expect(result).toBe(false); - }); - - it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner - ]), - ); - - const result = await firstValueFrom( - policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport), - ); - - expect(result).toBe(false); - }); - - it("returns false for organizations that do not use policies", async () => { - activeUserState.nextState( - arrayToRecord([ - policyData("policy2", "org1", PolicyType.ActivateAutofill, true), - policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies - ]), - ); - - const result = await firstValueFrom( - policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport), - ); - - expect(result).toBe(false); - }); - }); - - function policyData( - id: string, - organizationId: string, - type: PolicyType, - enabled: boolean, - data?: any, - ) { - const policyData = new PolicyData({} as any); - policyData.id = id as PolicyId; - policyData.organizationId = organizationId; - policyData.type = type; - policyData.enabled = enabled; - policyData.data = data; - - return policyData; - } - - function organizationData( - id: string, - enabled: boolean, - usePolicies: boolean, - status: OrganizationUserStatusType, - managePolicies: boolean, - type: OrganizationUserType = OrganizationUserType.User, - ) { - const organizationData = new OrganizationData({} as any, {} as any); - organizationData.id = id; - organizationData.enabled = enabled; - organizationData.usePolicies = usePolicies; - organizationData.status = status; - organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any); - organizationData.type = type; - return organizationData; - } - - function organization( - id: string, - enabled: boolean, - usePolicies: boolean, - status: OrganizationUserStatusType, - managePolicies: boolean, - type: OrganizationUserType = OrganizationUserType.User, - ) { - return new Organization( - organizationData(id, enabled, usePolicies, status, managePolicies, type), - ); - } - - function arrayToRecord(input: PolicyData[]): Record { - return Object.fromEntries(input.map((i) => [i.id, i])); - } -}); diff --git a/libs/common/src/admin-console/services/policy/policy.service.ts b/libs/common/src/admin-console/services/policy/policy.service.ts deleted file mode 100644 index ed4c7970a78..00000000000 --- a/libs/common/src/admin-console/services/policy/policy.service.ts +++ /dev/null @@ -1,257 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; - -import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state"; -import { PolicyId, UserId } from "../../../types/guid"; -import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; -import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction"; -import { OrganizationUserStatusType, PolicyType } from "../../enums"; -import { PolicyData } from "../../models/data/policy.data"; -import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options"; -import { Organization } from "../../models/domain/organization"; -import { Policy } from "../../models/domain/policy"; -import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options"; - -const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) => - Object.values(policiesMap || {}).map((f) => new Policy(f)); - -export const POLICIES = UserKeyDefinition.record(POLICIES_DISK, "policies", { - deserializer: (policyData) => policyData, - clearOn: ["logout"], -}); - -export class PolicyService implements InternalPolicyServiceAbstraction { - private activeUserPolicyState = this.stateProvider.getActive(POLICIES); - private activeUserPolicies$ = this.activeUserPolicyState.state$.pipe( - map((policyData) => policyRecordToArray(policyData)), - ); - - policies$ = this.activeUserPolicies$; - - constructor( - private stateProvider: StateProvider, - private organizationService: OrganizationService, - ) {} - - get$(policyType: PolicyType): Observable { - const filteredPolicies$ = this.activeUserPolicies$.pipe( - map((policies) => policies.filter((p) => p.type === policyType)), - ); - - const organizations$ = this.stateProvider.activeUserId$.pipe( - switchMap((userId) => this.organizationService.organizations$(userId)), - ); - - return combineLatest([filteredPolicies$, organizations$]).pipe( - map( - ([policies, organizations]) => - this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null, - ), - ); - } - - getAll$(policyType: PolicyType, userId: UserId) { - const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe( - map((policyData) => policyRecordToArray(policyData)), - map((policies) => policies.filter((p) => p.type === policyType)), - ); - - return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe( - map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)), - ); - } - - async getAll(policyType: PolicyType) { - return await firstValueFrom( - this.policies$.pipe(map((policies) => policies.filter((p) => p.type === policyType))), - ); - } - - policyAppliesToActiveUser$(policyType: PolicyType) { - return this.get$(policyType).pipe(map((policy) => policy != null)); - } - - async policyAppliesToUser(policyType: PolicyType) { - return await firstValueFrom(this.policyAppliesToActiveUser$(policyType)); - } - - private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) { - const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o])); - return policies.filter((policy) => { - const organization = orgDict[policy.organizationId]; - - // This shouldn't happen, i.e. the user should only have policies for orgs they are a member of - // But if it does, err on the side of enforcing the policy - if (organization == null) { - return true; - } - - return ( - policy.enabled && - organization.status >= OrganizationUserStatusType.Accepted && - organization.usePolicies && - !this.isExemptFromPolicy(policy.type, organization) - ); - }); - } - - masterPasswordPolicyOptions$(policies?: Policy[]): Observable { - const observable = policies ? of(policies) : this.policies$; - return observable.pipe( - map((obsPolicies) => { - let enforcedOptions: MasterPasswordPolicyOptions = null; - const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword); - - if (filteredPolicies == null || filteredPolicies.length === 0) { - return enforcedOptions; - } - - filteredPolicies.forEach((currentPolicy) => { - if (!currentPolicy.enabled || currentPolicy.data == null) { - return; - } - - if (enforcedOptions == null) { - enforcedOptions = new MasterPasswordPolicyOptions(); - } - - if ( - currentPolicy.data.minComplexity != null && - currentPolicy.data.minComplexity > enforcedOptions.minComplexity - ) { - enforcedOptions.minComplexity = currentPolicy.data.minComplexity; - } - - if ( - currentPolicy.data.minLength != null && - currentPolicy.data.minLength > enforcedOptions.minLength - ) { - enforcedOptions.minLength = currentPolicy.data.minLength; - } - - if (currentPolicy.data.requireUpper) { - enforcedOptions.requireUpper = true; - } - - if (currentPolicy.data.requireLower) { - enforcedOptions.requireLower = true; - } - - if (currentPolicy.data.requireNumbers) { - enforcedOptions.requireNumbers = true; - } - - if (currentPolicy.data.requireSpecial) { - enforcedOptions.requireSpecial = true; - } - - if (currentPolicy.data.enforceOnLogin) { - enforcedOptions.enforceOnLogin = true; - } - }); - - return enforcedOptions; - }), - ); - } - - evaluateMasterPassword( - passwordStrength: number, - newPassword: string, - enforcedPolicyOptions: MasterPasswordPolicyOptions, - ): boolean { - if (enforcedPolicyOptions == null) { - return true; - } - - if ( - enforcedPolicyOptions.minComplexity > 0 && - enforcedPolicyOptions.minComplexity > passwordStrength - ) { - return false; - } - - if ( - enforcedPolicyOptions.minLength > 0 && - enforcedPolicyOptions.minLength > newPassword.length - ) { - return false; - } - - if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) { - return false; - } - - if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) { - return false; - } - - if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) { - return false; - } - - // eslint-disable-next-line - if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) { - return false; - } - - return true; - } - - getResetPasswordPolicyOptions( - policies: Policy[], - orgId: string, - ): [ResetPasswordPolicyOptions, boolean] { - const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions(); - - if (policies == null || orgId == null) { - return [resetPasswordPolicyOptions, false]; - } - - const policy = policies.find( - (p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled, - ); - resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false; - - return [resetPasswordPolicyOptions, policy?.enabled ?? false]; - } - - async upsert(policy: PolicyData): Promise { - await this.activeUserPolicyState.update((policies) => { - policies ??= {}; - policies[policy.id] = policy; - return policies; - }); - } - - async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise { - await this.stateProvider.setUserState(POLICIES, policies, userId); - } - - /** - * Determines whether an orgUser is exempt from a specific policy because of their role - * Generally orgUsers who can manage policies are exempt from them, but some policies are stricter - */ - private isExemptFromPolicy(policyType: PolicyType, organization: Organization) { - switch (policyType) { - case PolicyType.MaximumVaultTimeout: - // Max Vault Timeout applies to everyone except owners - return organization.isOwner; - case PolicyType.PasswordGenerator: - // password generation policy applies to everyone - return false; - case PolicyType.PersonalOwnership: - // individual vault policy applies to everyone except admins and owners - return organization.isAdmin; - case PolicyType.FreeFamiliesSponsorshipPolicy: - // free Bitwarden families policy applies to everyone - return false; - case PolicyType.RemoveUnlockWithPin: - // free Remove Unlock with PIN policy applies to everyone - return false; - default: - return organization.canManagePolicies; - } - } -} diff --git a/libs/common/src/autofill/services/autofill-settings.service.ts b/libs/common/src/autofill/services/autofill-settings.service.ts index 869ae02c102..3346ef99a58 100644 --- a/libs/common/src/autofill/services/autofill-settings.service.ts +++ b/libs/common/src/autofill/services/autofill-settings.service.ts @@ -1,9 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { map, Observable } from "rxjs"; +import { map, Observable, switchMap } from "rxjs"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../admin-console/enums"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { getUserId } from "../../auth/services/account.service"; import { AUTOFILL_SETTINGS_DISK, AUTOFILL_SETTINGS_DISK_LOCAL, @@ -152,6 +154,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti constructor( private stateProvider: StateProvider, private policyService: PolicyService, + private accountService: AccountService, ) { this.autofillOnPageLoadState = this.stateProvider.getActive(AUTOFILL_ON_PAGE_LOAD); this.autofillOnPageLoad$ = this.autofillOnPageLoadState.state$.pipe(map((x) => x ?? false)); @@ -169,8 +172,11 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti this.autofillOnPageLoadCalloutIsDismissed$ = this.autofillOnPageLoadCalloutIsDismissedState.state$.pipe(map((x) => x ?? false)); - this.activateAutofillOnPageLoadFromPolicy$ = this.policyService.policyAppliesToActiveUser$( - PolicyType.ActivateAutofill, + this.activateAutofillOnPageLoadFromPolicy$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.ActivateAutofill, userId), + ), ); this.autofillOnPageLoadPolicyToastHasDisplayedState = this.stateProvider.getActive( 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 454a748344f..b5e9544b01b 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 @@ -175,7 +175,7 @@ describe("VaultTimeoutSettingsService", () => { "returns $expected when policy is $policy, and user preference is $userPreference", async ({ policy, userPreference, expected }) => { userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); - policyService.getAll$.mockReturnValue( + policyService.policiesByType$.mockReturnValue( of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])), ); @@ -213,7 +213,7 @@ describe("VaultTimeoutSettingsService", () => { userDecryptionOptionsSubject.next( new UserDecryptionOptions({ hasMasterPassword: false }), ); - policyService.getAll$.mockReturnValue( + policyService.policiesByType$.mockReturnValue( of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])), ); @@ -257,7 +257,7 @@ describe("VaultTimeoutSettingsService", () => { "when policy is %s, and vault timeout is %s, returns %s", async (policy, vaultTimeout, expected) => { userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); - policyService.getAll$.mockReturnValue( + policyService.policiesByType$.mockReturnValue( of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])), ); 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 f29687bd9cf..0716bf0bb93 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 @@ -9,7 +9,6 @@ import { distinctUntilChanged, firstValueFrom, from, - map, shareReplay, switchMap, tap, @@ -24,6 +23,7 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management"; import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; +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 { LogService } from "../../../platform/abstractions/log.service"; @@ -266,8 +266,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } return this.policyService - .getAll$(PolicyType.MaximumVaultTimeout, userId) - .pipe(map((policies) => policies[0] ?? null)); + .policiesByType$(PolicyType.MaximumVaultTimeout, userId) + .pipe(getFirstPolicy); } private async getAvailableVaultTimeoutActions(userId?: string): Promise { diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 79d194b87bc..2c6354f5b5a 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -329,7 +329,12 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private async handlePolicies() { combineLatest([ - this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + ), this.organizations$, ]) .pipe(takeUntil(this.destroy$)) 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 b50c7d23337..bcb8b3721ef 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -22,6 +22,7 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { MasterPasswordVerification, MasterPasswordVerificationResponse, @@ -584,7 +585,10 @@ export class LockComponent implements OnInit, OnDestroy { // If we do not have any saved policies, attempt to load them from the service if (this.enforcedMasterPasswordOptions == undefined) { this.enforcedMasterPasswordOptions = await firstValueFrom( - this.policyService.masterPasswordPolicyOptions$(), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)), + ), ); } diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index f808ff6802a..aecc6dcc330 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -14,7 +14,6 @@ import { import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/forms"; import { combineLatest, - firstValueFrom, map, merge, Observable, @@ -212,12 +211,18 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { this.formDisabled.emit(c === "DISABLED"); }); - // policies - this.disablePersonalVaultExportPolicy$ = this.policyService.policyAppliesToActiveUser$( - PolicyType.DisablePersonalVaultExport, + this.disablePersonalVaultExportPolicy$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.DisablePersonalVaultExport, userId), + ), ); - this.disablePersonalOwnershipPolicy$ = this.policyService.policyAppliesToActiveUser$( - PolicyType.PersonalOwnership, + + this.disablePersonalOwnershipPolicy$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), ); merge( @@ -227,8 +232,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { .pipe(startWith(0), takeUntil(this.destroy$)) .subscribe(() => this.adjustValidators()); - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - // Wire up the password generation for the password-protected export const account$ = this.accountService.activeAccount$.pipe( pin({ @@ -251,9 +254,14 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { }); if (this.organizationId) { - this.organizations$ = this.organizationService - .memberOrganizations$(userId) - .pipe(map((orgs) => orgs.filter((org) => org.id == this.organizationId))); + this.organizations$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.organizationService + .memberOrganizations$(userId) + .pipe(map((orgs) => orgs.filter((org) => org.id == this.organizationId))), + ), + ); this.exportForm.controls.vaultSelector.patchValue(this.organizationId); this.exportForm.controls.vaultSelector.disable(); @@ -263,7 +271,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { this.organizations$ = combineLatest({ collections: this.collectionService.decryptedCollections$, - memberOrganizations: this.organizationService.memberOrganizations$(userId), + memberOrganizations: this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.organizationService.memberOrganizations$(userId)), + ), }).pipe( map(({ collections, memberOrganizations }) => { const managedCollectionsOrgIds = new Set( diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 21522bdcb98..2bc8d514873 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -207,7 +207,7 @@ const providers = { describe("CredentialGeneratorService", () => { beforeEach(async () => { await accountService.switchAccount(SomeUser); - policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + policyService.policiesByType$.mockImplementation(() => new BehaviorSubject([]).asObservable()); i18nService.t.mockImplementation((key: string) => key); apiService.fetch.mockImplementation(() => Promise.resolve(mock())); jest.clearAllMocks(); @@ -567,7 +567,7 @@ describe("CredentialGeneratorService", () => { // awareness; they exercise the logic without being comprehensive it("enforces the active user's policy", async () => { const policy$ = new BehaviorSubject([passwordOverridePolicy]); - policyService.getAll$.mockReturnValue(policy$); + policyService.policiesByType$.mockReturnValue(policy$); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -578,15 +578,22 @@ describe("CredentialGeneratorService", () => { const result = await firstValueFrom(generator.algorithms$(["password"], { account$ })); - expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(policyService.policiesByType$).toHaveBeenCalledWith( + PolicyType.PasswordGenerator, + SomeUser, + ); expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy(); }); it("follows changes to the active user", async () => { const account$ = new BehaviorSubject(accounts[SomeUser]); - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passwordOverridePolicy]), + ); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passphraseOverridePolicy]), + ); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -603,7 +610,7 @@ describe("CredentialGeneratorService", () => { const [someResult, anotherResult] = results; - expect(policyService.getAll$).toHaveBeenNthCalledWith( + expect(policyService.policiesByType$).toHaveBeenNthCalledWith( 1, PolicyType.PasswordGenerator, SomeUser, @@ -611,7 +618,7 @@ describe("CredentialGeneratorService", () => { expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - expect(policyService.getAll$).toHaveBeenNthCalledWith( + expect(policyService.policiesByType$).toHaveBeenNthCalledWith( 2, PolicyType.PasswordGenerator, AnotherUser, @@ -621,7 +628,9 @@ describe("CredentialGeneratorService", () => { }); it("reads an arbitrary user's settings", async () => { - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passwordOverridePolicy]), + ); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -633,14 +642,21 @@ describe("CredentialGeneratorService", () => { const result = await firstValueFrom(generator.algorithms$("password", { account$ })); - expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); + expect(policyService.policiesByType$).toHaveBeenCalledWith( + PolicyType.PasswordGenerator, + AnotherUser, + ); expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); }); it("follows changes to the arbitrary user", async () => { - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passwordOverridePolicy]), + ); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passphraseOverridePolicy]), + ); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -658,17 +674,25 @@ describe("CredentialGeneratorService", () => { sub.unsubscribe(); const [someResult, anotherResult] = results; - expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(policyService.policiesByType$).toHaveBeenCalledWith( + PolicyType.PasswordGenerator, + SomeUser, + ); expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); - expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); + expect(policyService.policiesByType$).toHaveBeenCalledWith( + PolicyType.PasswordGenerator, + AnotherUser, + ); expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); }); it("errors when the arbitrary user's stream errors", async () => { - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passwordOverridePolicy]), + ); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -692,7 +716,9 @@ describe("CredentialGeneratorService", () => { }); it("completes when the arbitrary user's stream completes", async () => { - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passwordOverridePolicy]), + ); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -716,7 +742,9 @@ describe("CredentialGeneratorService", () => { }); it("ignores repeated arbitrary user emissions", async () => { - policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); + policyService.policiesByType$.mockReturnValueOnce( + new BehaviorSubject([passwordOverridePolicy]), + ); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -780,7 +808,7 @@ describe("CredentialGeneratorService", () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); const policy$ = new BehaviorSubject([somePolicy]); - policyService.getAll$.mockReturnValue(policy$); + policyService.policiesByType$.mockReturnValue(policy$); const generator = new CredentialGeneratorService( randomizer, policyService, @@ -908,7 +936,7 @@ describe("CredentialGeneratorService", () => { ); const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); - policyService.getAll$.mockReturnValue(policy$); + policyService.policiesByType$.mockReturnValue(policy$); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { account$ })); @@ -926,7 +954,7 @@ describe("CredentialGeneratorService", () => { const account = new BehaviorSubject(accounts[SomeUser]); const account$ = account.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); - policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable()); + policyService.policiesByType$.mockReturnValueOnce(somePolicySubject.asObservable()); const emissions: GeneratorConstraints[] = []; const sub = generator .policy$(SomeConfiguration, { account$ }) @@ -954,7 +982,9 @@ describe("CredentialGeneratorService", () => { const account$ = account.asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); const anotherPolicy$ = new BehaviorSubject([]).asObservable(); - policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$); + policyService.policiesByType$ + .mockReturnValueOnce(somePolicy$) + .mockReturnValueOnce(anotherPolicy$); const emissions: GeneratorConstraints[] = []; const sub = generator .policy$(SomeConfiguration, { account$ }) diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index f8ef11cbbe6..eacc2ca6fc5 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -114,11 +114,13 @@ export class CredentialGeneratorService { const algorithms$ = dependencies.account$.pipe( distinctUntilChanged(), switchMap((account) => { - const policies$ = this.policyService.getAll$(PolicyType.PasswordGenerator, account.id).pipe( - map((p) => new Set(availableAlgorithms(p))), - // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely - takeUntil(anyComplete(dependencies.account$)), - ); + const policies$ = this.policyService + .policiesByType$(PolicyType.PasswordGenerator, account.id) + .pipe( + map((p) => new Set(availableAlgorithms(p))), + // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely + takeUntil(anyComplete(dependencies.account$)), + ); return policies$; }), map((available) => { @@ -280,7 +282,7 @@ export class CredentialGeneratorService { switchMap(({ userId, email }) => { // complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely const policies$ = this.policyService - .getAll$(configuration.policy.type, userId) + .policiesByType$(configuration.policy.type, userId) .pipe( mapPolicyToConstraints(configuration.policy, email), takeUntil(anyComplete(dependencies.account$)), diff --git a/libs/tools/generator/core/src/services/default-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-generator.service.spec.ts index 4bef94108f0..eb9642a9417 100644 --- a/libs/tools/generator/core/src/services/default-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/default-generator.service.spec.ts @@ -19,7 +19,7 @@ function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); const stateValue = config?.state ?? new BehaviorSubject([null]); - service.getAll$.mockReturnValue(stateValue); + service.policiesByType$.mockReturnValue(stateValue); return service; } @@ -103,7 +103,7 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); - expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + expect(policy.policiesByType$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); }); it("should map the policy using the generation strategy", async () => { @@ -150,7 +150,7 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); await firstValueFrom(service.evaluator$(SomeUser)); - expect(policy.getAll$).toHaveBeenCalledTimes(1); + expect(policy.policiesByType$).toHaveBeenCalledTimes(1); }); it("should cache the password generator policy for each user", async () => { @@ -161,8 +161,16 @@ describe("Password generator service", () => { await firstValueFrom(service.evaluator$(SomeUser)); await firstValueFrom(service.evaluator$(AnotherUser)); - expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); - expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); + expect(policy.policiesByType$).toHaveBeenNthCalledWith( + 1, + PolicyType.PasswordGenerator, + SomeUser, + ); + expect(policy.policiesByType$).toHaveBeenNthCalledWith( + 2, + PolicyType.PasswordGenerator, + AnotherUser, + ); }); }); diff --git a/libs/tools/generator/core/src/services/default-generator.service.ts b/libs/tools/generator/core/src/services/default-generator.service.ts index 12cfefdd5c6..d4ffa16484b 100644 --- a/libs/tools/generator/core/src/services/default-generator.service.ts +++ b/libs/tools/generator/core/src/services/default-generator.service.ts @@ -51,7 +51,7 @@ export class DefaultGeneratorService implements GeneratorServic } private createEvaluator(userId: UserId) { - const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe( + const evaluator$ = this.policy.policiesByType$(this.strategy.policy, userId).pipe( // create the evaluator from the policies this.strategy.toEvaluator(), ); diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts index 5eafacbef52..aeb1a648a14 100644 --- a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts +++ b/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts @@ -155,7 +155,7 @@ const NoPolicyProfile: CoreProfileMetadata = { describe("GeneratorProfileProvider", () => { beforeEach(async () => { - policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + policyService.policiesByType$.mockImplementation(() => new BehaviorSubject([]).asObservable()); const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor }); encryptorProvider.userEncryptor$.mockReturnValue(encryptor$); jest.clearAllMocks(); @@ -211,7 +211,7 @@ describe("GeneratorProfileProvider", () => { const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); - policyService.getAll$.mockReturnValue(policy$); + policyService.policiesByType$.mockReturnValue(policy$); const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ })); @@ -223,7 +223,7 @@ describe("GeneratorProfileProvider", () => { const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); const expectedPolicy = [somePolicy]; const policy$ = new BehaviorSubject(expectedPolicy); - policyService.getAll$.mockReturnValue(policy$); + policyService.policiesByType$.mockReturnValue(policy$); await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ })); @@ -284,7 +284,7 @@ describe("GeneratorProfileProvider", () => { const account = new BehaviorSubject(accounts[SomeUser]); const account$ = account.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); - policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable()); + policyService.policiesByType$.mockReturnValueOnce(somePolicySubject.asObservable()); const emissions: GeneratorConstraints[] = []; const sub = profileProvider .constraints$(SomeProfile, { account$ }) diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.ts b/libs/tools/generator/core/src/services/generator-profile-provider.ts index 24835e948fd..7088e23d3fe 100644 --- a/libs/tools/generator/core/src/services/generator-profile-provider.ts +++ b/libs/tools/generator/core/src/services/generator-profile-provider.ts @@ -86,7 +86,7 @@ export class GeneratorProfileProvider { ); const policies$ = profile.constraints.type - ? this.policyService.getAll$(profile.constraints.type, account.id) + ? this.policyService.policiesByType$(profile.constraints.type, account.id) : of([]); const context: ProfileContext = { diff --git a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts index 1e558ab352e..65f1669ebd1 100644 --- a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts @@ -47,7 +47,7 @@ describe("DefaultGeneratorNavigationService", () => { describe("evaluator$", () => { it("emits a GeneratorNavigationEvaluator", async () => { const policyService = mock({ - getAll$() { + policiesByType$() { return of([]); }, }); @@ -62,7 +62,7 @@ describe("DefaultGeneratorNavigationService", () => { describe("enforcePolicy", () => { it("applies policy", async () => { const policyService = mock({ - getAll$(_type: PolicyType, _user: UserId) { + policiesByType$(_type: PolicyType, _user: UserId) { return of([ new Policy({ id: "" as any, diff --git a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.ts b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.ts index 10781786cfe..7189a7095f0 100644 --- a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.ts +++ b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.ts @@ -41,7 +41,7 @@ export class DefaultGeneratorNavigationService implements GeneratorNavigationSer * @param userId: Identifies the user making the request */ evaluator$(userId: UserId) { - const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe( + const evaluator$ = this.policy.policiesByType$(PolicyType.PasswordGenerator, userId).pipe( reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy), distinctIfShallowMatch(), map((policy) => new GeneratorNavigationEvaluator(policy)), 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 ce2ff14d4b0..8d24fc4acee 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 @@ -99,7 +99,7 @@ export class SendOptionsComponent implements OnInit { this.accountService.activeAccount$ .pipe( getUserId, - switchMap((userId) => this.policyService.getAll$(PolicyType.SendOptions, userId)), + switchMap((userId) => this.policyService.policiesByType$(PolicyType.SendOptions, userId)), map((policies) => policies?.some((p) => p.data.disableHideEmail)), takeUntilDestroyed(), ) diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts index b222aa6cc1a..343fa880795 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form-config.service.ts @@ -1,10 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { inject, Injectable } from "@angular/core"; -import { combineLatest, firstValueFrom, map } from "rxjs"; +import { combineLatest, firstValueFrom, map, switchMap } from "rxjs"; 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SendId } from "@bitwarden/common/types/guid"; @@ -22,6 +24,7 @@ import { export class DefaultSendFormConfigService implements SendFormConfigService { private policyService: PolicyService = inject(PolicyService); private sendService: SendService = inject(SendService); + private accountService: AccountService = inject(AccountService); async buildConfig( mode: SendFormMode, @@ -40,9 +43,11 @@ export class DefaultSendFormConfigService implements SendFormConfigService { }; } - private areSendsEnabled$ = this.policyService - .policyAppliesToActiveUser$(PolicyType.DisableSend) - .pipe(map((p) => !p)); + private areSendsEnabled$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId)), + map((p) => !p), + ); private getSend(id?: SendId) { if (id == null) { diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts index b9add41c222..8e1dde22324 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-config.service.ts @@ -89,9 +89,13 @@ export class DefaultCipherFormConfigService implements CipherFormConfigService { ); } - private allowPersonalOwnership$ = this.policyService - .policyAppliesToActiveUser$(PolicyType.PersonalOwnership) - .pipe(map((p) => !p)); + private allowPersonalOwnership$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId), + ), + map((p) => !p), + ); private getCipher(userId: UserId, id?: CipherId): Promise { if (id == null) { From d0c91db3b3fa98fda51d59362652943f9974d895 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 25 Mar 2025 16:56:36 +0100 Subject: [PATCH 010/228] [CL-614] More spacing fixes (#13955) * Fix all dialogs to use ng-container for bitDialogFooter * Fix button spacing * Fix send icon spacing --- .../popup/components/set-pin.component.html | 4 +- .../at-risk-carousel-dialog.component.html | 4 +- .../auth/components/set-pin.component.html | 4 +- .../src/auth/delete-account.component.html | 4 +- .../settings/account.component.html | 14 ++- .../delete-organization-dialog.component.html | 4 +- .../account/change-email.component.html | 14 ++- .../emergency-access-confirm.component.html | 4 +- .../emergency-access-takeover.component.html | 4 +- .../settings/security/api-key.component.html | 4 +- .../security/security-keys.component.html | 16 +-- .../security/security-keys.component.ts | 7 +- .../src/app/tools/send/send.component.html | 106 +++++++++--------- .../bulk-confirmation-dialog.component.html | 4 +- .../dialog/import-error-dialog.component.html | 4 +- ...dd-edit-custom-field-dialog.component.html | 4 +- .../add-edit-folder-dialog.component.html | 4 +- 17 files changed, 103 insertions(+), 102 deletions(-) diff --git a/apps/browser/src/auth/popup/components/set-pin.component.html b/apps/browser/src/auth/popup/components/set-pin.component.html index 80e1b63c7d7..58cb42456ee 100644 --- a/apps/browser/src/auth/popup/components/set-pin.component.html +++ b/apps/browser/src/auth/popup/components/set-pin.component.html @@ -25,13 +25,13 @@ {{ "lockWithMasterPassOnRestart1" | i18n }} -
+ -
+
diff --git a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html index aee456a8f2b..c5e3ea8fb0d 100644 --- a/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html +++ b/apps/browser/src/vault/popup/components/at-risk-carousel-dialog/at-risk-carousel-dialog.component.html @@ -39,7 +39,7 @@ -
+ -
+ diff --git a/apps/desktop/src/auth/components/set-pin.component.html b/apps/desktop/src/auth/components/set-pin.component.html index cadd5340bb2..6fb5829b79a 100644 --- a/apps/desktop/src/auth/components/set-pin.component.html +++ b/apps/desktop/src/auth/components/set-pin.component.html @@ -25,13 +25,13 @@ {{ "lockWithMasterPassOnRestart1" | i18n }} -
+ -
+ diff --git a/apps/desktop/src/auth/delete-account.component.html b/apps/desktop/src/auth/delete-account.component.html index 42c06b74891..0d5eec29bf4 100644 --- a/apps/desktop/src/auth/delete-account.component.html +++ b/apps/desktop/src/auth/delete-account.component.html @@ -18,13 +18,13 @@

{{ "confirmIdentity" | i18n }}

-
+ -
+ diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index ae1ecb6f3bb..8e3d63a337d 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -42,12 +42,14 @@ {{ "learnMoreAboutApi" | i18n }}

- - +
+ + +
-
+ -
+
diff --git a/apps/web/src/app/auth/settings/account/change-email.component.html b/apps/web/src/app/auth/settings/account/change-email.component.html index b097047e663..d4462c3b056 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.html +++ b/apps/web/src/app/auth/settings/account/change-email.component.html @@ -39,10 +39,12 @@ - - +
+ + +
diff --git a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.html b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.html index a8a4bd53d90..d3815ffcd07 100644 --- a/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.html +++ b/apps/web/src/app/auth/settings/emergency-access/confirm/emergency-access-confirm.component.html @@ -25,13 +25,13 @@ {{ "dontAskFingerprintAgain" | i18n }} -
+ -
+ diff --git a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html index 102f8499015..64b35344455 100644 --- a/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html +++ b/apps/web/src/app/auth/settings/emergency-access/takeover/emergency-access-takeover.component.html @@ -42,13 +42,13 @@ -
+ -
+ diff --git a/apps/web/src/app/auth/settings/security/api-key.component.html b/apps/web/src/app/auth/settings/security/api-key.component.html index b9416305acc..a352f9b0dad 100644 --- a/apps/web/src/app/auth/settings/security/api-key.component.html +++ b/apps/web/src/app/auth/settings/security/api-key.component.html @@ -30,13 +30,13 @@

-
+ -
+ diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.html b/apps/web/src/app/auth/settings/security/security-keys.component.html index acfe4319c95..1f8d9dfeb00 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.html +++ b/apps/web/src/app/auth/settings/security/security-keys.component.html @@ -8,11 +8,11 @@

{{ "userApiKeyDesc" | i18n }}

- - - - +
+ + +
diff --git a/apps/web/src/app/auth/settings/security/security-keys.component.ts b/apps/web/src/app/auth/settings/security/security-keys.component.ts index 78b48bea8b4..70e33b242b3 100644 --- a/apps/web/src/app/auth/settings/security/security-keys.component.ts +++ b/apps/web/src/app/auth/settings/security/security-keys.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, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -15,11 +15,6 @@ import { ApiKeyComponent } from "./api-key.component"; templateUrl: "security-keys.component.html", }) export class SecurityKeysComponent implements OnInit { - @ViewChild("viewUserApiKeyTemplate", { read: ViewContainerRef, static: true }) - viewUserApiKeyModalRef: ViewContainerRef; - @ViewChild("rotateUserApiKeyTemplate", { read: ViewContainerRef, static: true }) - rotateUserApiKeyModalRef: ViewContainerRef; - showChangeKdf = true; constructor( diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 6f690459bb0..e1d921af940 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -93,58 +93,60 @@ - - - - - {{ "disabled" | i18n }} - - - - {{ "password" | i18n }} - - - - {{ "maxAccessCountReached" | i18n }} - - - - {{ "expired" | i18n }} - - - - {{ "pendingDeletion" | i18n }} - +
+ + + + + {{ "disabled" | i18n }} + + + + {{ "password" | i18n }} + + + + {{ "maxAccessCountReached" | i18n }} + + + + {{ "expired" | i18n }} + + + + {{ "pendingDeletion" | i18n }} + +
{{ s.deletionDate | date: "medium" }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html index ff0c21e4525..ee4e0ec5233 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.html @@ -21,7 +21,7 @@ -
+ -
+ diff --git a/libs/importer/src/components/dialog/import-error-dialog.component.html b/libs/importer/src/components/dialog/import-error-dialog.component.html index c1ad8b53932..da4cc1de421 100644 --- a/libs/importer/src/components/dialog/import-error-dialog.component.html +++ b/libs/importer/src/components/dialog/import-error-dialog.component.html @@ -21,9 +21,9 @@ -
+ -
+ diff --git a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html index 1ad42f95ec2..f379f466b4a 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/add-edit-custom-field-dialog/add-edit-custom-field-dialog.component.html @@ -26,7 +26,7 @@ -
+ @@ -43,6 +43,6 @@ [appA11yTitle]="'deleteCustomField' | i18n: customFieldForm.value.label" (click)="removeField()" > -
+ diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html index 4869714332c..cefd6305973 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.html @@ -12,7 +12,7 @@ -
+ -
+ From abb314a0e78e326bc248c43182cf7fba503d5516 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 25 Mar 2025 13:32:11 -0400 Subject: [PATCH 011/228] [PM-19432] Fix Multiple WS Connections (#13985) * Test facilitation changes * Fix multiple connections to SignalR --- .../internal/signalr-connection.service.ts | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts index e5d210266c0..8bea98cb506 100644 --- a/libs/common/src/platform/notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/notifications/internal/signalr-connection.service.ts @@ -23,6 +23,11 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp export type SignalRNotification = Heartbeat | ReceiveMessage; +export type TimeoutManager = { + setTimeout: (handler: TimerHandler, timeout: number) => number; + clearTimeout: (timeoutId: number) => void; +}; + class SignalRLogger implements ILogger { constructor(private readonly logService: LogService) {} @@ -51,11 +56,14 @@ export class SignalRConnectionService { constructor( private readonly apiService: ApiService, private readonly logService: LogService, + private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () => + new HubConnectionBuilder(), + private readonly timeoutManager: TimeoutManager = globalThis, ) {} connect$(userId: UserId, notificationsUrl: string) { return new Observable((subsciber) => { - const connection = new HubConnectionBuilder() + const connection = this.hubConnectionBuilderFactory() .withUrl(notificationsUrl + "/hub", { accessTokenFactory: () => this.apiService.getActiveBearerToken(), skipNegotiation: true, @@ -76,48 +84,60 @@ export class SignalRConnectionService { let reconnectSubscription: Subscription | null = null; // Create schedule reconnect function - const scheduleReconnect = (): Subscription => { + const scheduleReconnect = () => { if ( connection == null || connection.state !== HubConnectionState.Disconnected || (reconnectSubscription != null && !reconnectSubscription.closed) ) { - return Subscription.EMPTY; + // Skip scheduling a new reconnect, either the connection isn't disconnected + // or an active reconnect is already scheduled. + return; } - const randomTime = this.random(); - const timeoutHandler = setTimeout(() => { + // If we've somehow gotten here while the subscriber is closed, + // we do not want to reconnect. So leave. + if (subsciber.closed) { + return; + } + + const randomTime = this.randomReconnectTime(); + const timeoutHandler = this.timeoutManager.setTimeout(() => { connection .start() - .then(() => (reconnectSubscription = null)) + .then(() => { + reconnectSubscription = null; + }) .catch(() => { - reconnectSubscription = scheduleReconnect(); + scheduleReconnect(); }); }, randomTime); - return new Subscription(() => clearTimeout(timeoutHandler)); + reconnectSubscription = new Subscription(() => + this.timeoutManager.clearTimeout(timeoutHandler), + ); }; connection.onclose((error) => { - reconnectSubscription = scheduleReconnect(); + scheduleReconnect(); }); // Start connection connection.start().catch(() => { - reconnectSubscription = scheduleReconnect(); + scheduleReconnect(); }); return () => { + // Cancel any possible scheduled reconnects + reconnectSubscription?.unsubscribe(); connection?.stop().catch((error) => { this.logService.error("Error while stopping SignalR connection", error); - // TODO: Does calling stop call `onclose`? - reconnectSubscription?.unsubscribe(); }); }; }); } - private random() { + private randomReconnectTime() { return ( Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME ); From 15b2b46b85b16e3e477dc562ccac80e2a9f2bdab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 25 Mar 2025 17:08:30 -0400 Subject: [PATCH 012/228] [PM-18665] introduce metadata provider (#13744) --- .../integration/integration-context.spec.ts | 15 +- .../tools/integration/integration-metadata.ts | 4 +- .../src/tools/log/disabled-semantic-logger.ts | 8 +- .../tools/log/semantic-logger.abstraction.ts | 1 + .../generator/core/src/data/integrations.ts | 8 +- .../generator/core/src/integration/addy-io.ts | 3 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 3 +- .../core/src/metadata/algorithm-metadata.ts | 16 +- .../core/src/metadata/email/catchall.spec.ts | 6 +- .../core/src/metadata/email/forwarder.ts | 79 +++- .../src/metadata/email/plus-address.spec.ts | 6 +- .../generator/core/src/metadata/index.ts | 18 +- .../metadata/password/eff-word-list.spec.ts | 28 +- .../metadata/password/random-password.spec.ts | 12 +- .../metadata/username/eff-word-list.spec.ts | 6 +- .../policies/available-algorithms-policy.ts | 32 +- .../generator-metadata-provider.spec.ts | 438 ++++++++++++++++++ .../services/generator-metadata-provider.ts | 252 ++++++++++ .../credential-generator-configuration.ts | 4 +- .../core/src/types/generator-type.ts | 8 +- .../core/src/types/metadata-request.ts | 13 + 25 files changed, 910 insertions(+), 62 deletions(-) create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.ts create mode 100644 libs/tools/generator/core/src/types/metadata-request.ts diff --git a/libs/common/src/tools/integration/integration-context.spec.ts b/libs/common/src/tools/integration/integration-context.spec.ts index 42581c08dee..67a40afb337 100644 --- a/libs/common/src/tools/integration/integration-context.spec.ts +++ b/libs/common/src/tools/integration/integration-context.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { I18nService } from "../../platform/abstractions/i18n.service"; +import { VendorId } from "../extension"; import { IntegrationContext } from "./integration-context"; import { IntegrationId } from "./integration-id"; @@ -8,7 +9,7 @@ import { IntegrationMetadata } from "./integration-metadata"; const EXAMPLE_META = Object.freeze({ // arbitrary - id: "simplelogin" as IntegrationId, + id: "simplelogin" as IntegrationId & VendorId, name: "Example", // arbitrary extends: ["forwarder"], @@ -34,7 +35,7 @@ describe("IntegrationContext", () => { it("throws when the baseurl isn't defined in metadata", () => { const noBaseUrl: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary selfHost: "maybe", @@ -56,7 +57,7 @@ describe("IntegrationContext", () => { it("ignores settings when selfhost is 'never'", () => { const selfHostNever: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -71,7 +72,7 @@ describe("IntegrationContext", () => { it("always reads the settings when selfhost is 'always'", () => { const selfHostAlways: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -86,7 +87,7 @@ describe("IntegrationContext", () => { it("fails when the settings are empty and selfhost is 'always'", () => { const selfHostAlways: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -101,7 +102,7 @@ describe("IntegrationContext", () => { it("reads from the metadata by default when selfhost is 'maybe'", () => { const selfHostMaybe: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", @@ -117,7 +118,7 @@ describe("IntegrationContext", () => { it("overrides the metadata when selfhost is 'maybe'", () => { const selfHostMaybe: IntegrationMetadata = { - id: "simplelogin" as IntegrationId, // arbitrary + id: "simplelogin" as IntegrationId & VendorId, // arbitrary name: "Example", extends: ["forwarder"], // arbitrary baseUrl: "example.com", diff --git a/libs/common/src/tools/integration/integration-metadata.ts b/libs/common/src/tools/integration/integration-metadata.ts index e460aae828c..2073b16feb0 100644 --- a/libs/common/src/tools/integration/integration-metadata.ts +++ b/libs/common/src/tools/integration/integration-metadata.ts @@ -1,10 +1,12 @@ +import { VendorId } from "../extension"; + import { ExtensionPointId } from "./extension-point-id"; import { IntegrationId } from "./integration-id"; /** The capabilities and descriptive content for an integration */ export type IntegrationMetadata = { /** Uniquely identifies the integrator. */ - id: IntegrationId; + id: IntegrationId & VendorId; /** Brand name of the integrator. */ name: string; diff --git a/libs/common/src/tools/log/disabled-semantic-logger.ts b/libs/common/src/tools/log/disabled-semantic-logger.ts index 054c3ed390b..21ea48bbe51 100644 --- a/libs/common/src/tools/log/disabled-semantic-logger.ts +++ b/libs/common/src/tools/log/disabled-semantic-logger.ts @@ -12,7 +12,11 @@ export class DisabledSemanticLogger implements SemanticLogger { error(_content: Jsonify, _message?: string): void {} - panic(_content: Jsonify, message?: string): never { - throw new Error(message); + panic(content: Jsonify, message?: string): never { + if (typeof content === "string" && !message) { + throw new Error(content); + } else { + throw new Error(message); + } } } diff --git a/libs/common/src/tools/log/semantic-logger.abstraction.ts b/libs/common/src/tools/log/semantic-logger.abstraction.ts index 196d1f3f12c..51aaa917378 100644 --- a/libs/common/src/tools/log/semantic-logger.abstraction.ts +++ b/libs/common/src/tools/log/semantic-logger.abstraction.ts @@ -9,6 +9,7 @@ export interface SemanticLogger { */ debug(message: string): void; + // FIXME: replace Jsonify parameter with structural logging schema type /** Logs the content at debug priority. * Debug messages are used for diagnostics, and are typically disabled * in production builds. diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts index 21c883cae02..ffe4676fcd7 100644 --- a/libs/tools/generator/core/src/data/integrations.ts +++ b/libs/tools/generator/core/src/data/integrations.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; @@ -29,8 +30,11 @@ export const Integrations = Object.freeze({ const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i])); -export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration { - const maybeForwarder = integrations.get(id); +export function getForwarderConfiguration( + id: IntegrationId | VendorId, +): ForwarderConfiguration { + // these casts are for compatibility; `IntegrationId` is the old form of `VendorId` + const maybeForwarder = integrations.get(id as string as IntegrationId & VendorId); if (maybeForwarder && "forwarder" in maybeForwarder) { return maybeForwarder as ForwarderConfiguration; diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index d9f2b9f121d..631c5fdb510 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -100,7 +101,7 @@ const forwarder = Object.freeze({ export const AddyIo = Object.freeze({ // integration - id: "anonaddy" as IntegrationId, + id: "anonaddy" as IntegrationId & VendorId, name: "Addy.io", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts index 0bcdd560503..d2bd6173a14 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -89,7 +90,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const DuckDuckGo = Object.freeze({ - id: "duckduckgo" as IntegrationId, + id: "duckduckgo" as IntegrationId & VendorId, name: "DuckDuckGo", baseUrl: "https://quack.duckduckgo.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index 69b908badc9..bfde1aa70f5 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -5,6 +5,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -159,7 +160,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const Fastmail = Object.freeze({ - id: "fastmail" as IntegrationId, + id: "fastmail" as IntegrationId & VendorId, name: "Fastmail", baseUrl: "https://api.fastmail.com", selfHost: "maybe", diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index ae65611905f..9f40a3631ff 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -97,7 +98,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const FirefoxRelay = Object.freeze({ - id: "firefoxrelay" as IntegrationId, + id: "firefoxrelay" as IntegrationId & VendorId, name: "Firefox Relay", baseUrl: "https://relay.firefox.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index d67b8d588bf..34b4602b94b 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; @@ -101,7 +102,7 @@ const forwarder = Object.freeze({ export const ForwardEmail = Object.freeze({ // integration metadata - id: "forwardemail" as IntegrationId, + id: "forwardemail" as IntegrationId & VendorId, name: "Forward Email", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 1581f3861f5..efbac69cec2 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -103,7 +104,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const SimpleLogin = Object.freeze({ - id: "simplelogin" as IntegrationId, + id: "simplelogin" as IntegrationId & VendorId, name: "SimpleLogin", selfHost: "maybe", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts index f776dd76e54..c07deef5535 100644 --- a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts +++ b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts @@ -1,5 +1,7 @@ import { CredentialAlgorithm, CredentialType } from "./type"; +type I18nKeyOrLiteral = string | { literal: string }; + /** Credential generator metadata common across credential generators */ export type AlgorithmMetadata = { /** Uniquely identifies the credential configuration @@ -23,25 +25,25 @@ export type AlgorithmMetadata = { /** Localization keys */ i18nKeys: { /** descriptive name of the algorithm */ - name: string; + name: I18nKeyOrLiteral; /** explanatory text for the algorithm */ - description?: string; + description?: I18nKeyOrLiteral; /** labels the generate action */ - generateCredential: string; + generateCredential: I18nKeyOrLiteral; /** message informing users when the generator produces a new credential */ - credentialGenerated: string; + credentialGenerated: I18nKeyOrLiteral; /* labels the action that assigns a generated value to a domain object */ - useCredential: string; + useCredential: I18nKeyOrLiteral; /** labels the generated output */ - credentialType: string; + credentialType: I18nKeyOrLiteral; /** labels the copy output action */ - copyCredential: string; + copyCredential: I18nKeyOrLiteral; }; /** fine-tunings for generator user experiences */ diff --git a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts index f63f141842c..d6cc1795e0b 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts @@ -19,11 +19,13 @@ describe("email - catchall generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = catchall.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts index 1dfc219d466..f4f150f33fa 100644 --- a/libs/tools/generator/core/src/metadata/email/forwarder.ts +++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts @@ -1,4 +1,75 @@ -// Forwarders are pending integration with the extension API -// -// They use the 300-block of weights and derive their metadata -// using logic similar to `toCredentialGeneratorConfiguration` +import { ExtensionMetadata, ExtensionStorageKey } from "@bitwarden/common/tools/extension/type"; +import { SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; + +import { getForwarderConfiguration } from "../../data"; +import { EmailDomainSettings, EmailPrefixSettings } from "../../engine"; +import { Forwarder } from "../../engine/forwarder"; +import { GeneratorDependencyProvider } from "../../types"; +import { Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; +import { ForwarderProfileMetadata } from "../profile-metadata"; + +// These options are used by all forwarders; each forwarder uses a different set, +// as defined by `GeneratorMetadata.capabilities.fields`. +type ForwarderOptions = Partial; + +// update the extension metadata +export function toForwarderMetadata( + extension: ExtensionMetadata, +): GeneratorMetadata { + if (extension.site.id !== "forwarder") { + throw new Error( + `expected forwarder extension; received ${extension.site.id} (${extension.product.vendor.id})`, + ); + } + + const name = { literal: extension.product.name ?? extension.product.vendor.name }; + + const generator: GeneratorMetadata = { + id: { forwarder: extension.product.vendor.id }, + category: Type.email, + weight: 300, + i18nKeys: { + name, + description: "forwardedEmailDesc", + generateCredential: "generateEmail", + credentialGenerated: "emailGenerated", + useCredential: "useThisEmail", + credentialType: "email", + copyCredential: "copyEmail", + }, + capabilities: { + autogenerate: false, + fields: [...extension.requestedFields], + }, + engine: { + create(dependencies: GeneratorDependencyProvider) { + const config = getForwarderConfiguration(extension.product.vendor.id); + return new Forwarder(config, dependencies.client, dependencies.i18nService); + }, + }, + profiles: { + [Profile.account]: { + type: "extension", + site: "forwarder", + storage: { + key: "forwarder", + frame: 512, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ExtensionStorageKey, + constraints: { + default: {}, + create() { + return new IdentityConstraint(); + }, + }, + } satisfies ForwarderProfileMetadata, + }, + }; + + return generator; +} diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts index 2ac7645ed30..063cb71c23a 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts @@ -19,11 +19,13 @@ describe("email - plus address generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = plusAddress.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts index 79806fd1bcc..d9437822270 100644 --- a/libs/tools/generator/core/src/metadata/index.ts +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -1,12 +1,24 @@ -import { AlgorithmsByType as ABT } from "./data"; +import { + Algorithm as AlgorithmData, + AlgorithmsByType as AlgorithmsByTypeData, + Type as TypeData, +} from "./data"; import { CredentialType, CredentialAlgorithm } from "./type"; // `CredentialAlgorithm` is defined in terms of `ABT`; supplying // type information in the barrel file breaks a circular dependency. /** Credential generation algorithms grouped by purpose. */ -export const AlgorithmsByType: Record> = ABT; +export const AlgorithmsByType: Record< + CredentialType, + ReadonlyArray +> = AlgorithmsByTypeData; +export const Algorithms: ReadonlyArray = Object.freeze( + Object.values(AlgorithmData), +); +export const Types: ReadonlyArray = Object.freeze(Object.values(TypeData)); -export { Profile, Type } from "./data"; +export { Profile, Type, Algorithm } from "./data"; +export { toForwarderMetadata } from "./email/forwarder"; export { GeneratorMetadata } from "./generator-metadata"; export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts index 57961a60033..e02d63d3d59 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -22,19 +22,21 @@ describe("password - eff words generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata | null = null; beforeEach(() => { const profile = effPassphrase.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + accountProfile = null; } }); describe("storage.options.deserializer", () => { it("returns its input", () => { - const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial }; + const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial }; - const result = accountProfile.storage.options.deserializer(value); + const result = accountProfile!.storage.options.deserializer(value); expect(result).toBe(value); }); @@ -46,15 +48,15 @@ describe("password - eff words generator metadata", () => { // enclosed behaviors change. it("creates a passphrase policy constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; - const constraints = accountProfile.constraints.create([], context); + const constraints = accountProfile!.constraints.create([], context); expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints); }); it("forwards the policy to the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; const policies = [ { type: PolicyType.PasswordGenerator, @@ -66,13 +68,13 @@ describe("password - eff words generator metadata", () => { }, ] as Policy[]; - const constraints = accountProfile.constraints.create(policies, context); + const constraints = accountProfile!.constraints.create(policies, context); - expect(constraints.constraints.numWords.min).toEqual(6); + expect(constraints.constraints.numWords?.min).toEqual(6); }); it("combines multiple policies in the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; const policies = [ { type: PolicyType.PasswordGenerator, @@ -92,10 +94,10 @@ describe("password - eff words generator metadata", () => { }, ] as Policy[]; - const constraints = accountProfile.constraints.create(policies, context); + const constraints = accountProfile!.constraints.create(policies, context); - expect(constraints.constraints.numWords.min).toEqual(6); - expect(constraints.constraints.capitalize.requiredValue).toEqual(true); + expect(constraints.constraints.numWords?.min).toEqual(6); + expect(constraints.constraints.capitalize?.requiredValue).toEqual(true); }); }); }); diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts index d91ceaac248..9e38c50ee2a 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -22,11 +22,13 @@ describe("password - characters generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = password.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); @@ -69,7 +71,7 @@ describe("password - characters generator metadata", () => { const constraints = accountProfile.constraints.create(policies, context); - expect(constraints.constraints.length.min).toEqual(10); + expect(constraints.constraints.length?.min).toEqual(10); }); it("combines multiple policies in the constraints", () => { @@ -97,8 +99,8 @@ describe("password - characters generator metadata", () => { const constraints = accountProfile.constraints.create(policies, context); - expect(constraints.constraints.length.min).toEqual(14); - expect(constraints.constraints.special.requiredValue).toEqual(true); + expect(constraints.constraints.length?.min).toEqual(14); + expect(constraints.constraints.special?.requiredValue).toEqual(true); }); }); }); diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts index aba9680a448..d47d5ec9fcb 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts @@ -20,11 +20,13 @@ describe("username - eff words generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = effWordList.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts index f37a8b21a3f..0c44a1a0408 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts @@ -5,13 +5,41 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { CredentialAlgorithm, EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from ".."; +import { + CredentialAlgorithm as LegacyAlgorithm, + EmailAlgorithms, + PasswordAlgorithms, + UsernameAlgorithms, +} from ".."; +import { CredentialAlgorithm } from "../metadata"; /** Reduces policies to a set of available algorithms * @param policies the policies to reduce * @returns the resulting `AlgorithmAvailabilityPolicy` */ -export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] { +export function availableAlgorithms(policies: Policy[]): LegacyAlgorithm[] { + const overridePassword = policies + .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) + .reduce( + (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), + null as LegacyAlgorithm, + ); + + const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; + if (overridePassword) { + policy.push(overridePassword); + } else { + policy.push(...PasswordAlgorithms); + } + + return policy; +} + +/** Reduces policies to a set of available algorithms + * @param policies the policies to reduce + * @returns the resulting `AlgorithmAvailabilityPolicy` + */ +export function availableAlgorithms_vNext(policies: Policy[]): CredentialAlgorithm[] { const overridePassword = policies .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) .reduce( diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts new file mode 100644 index 00000000000..958e5608449 --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.spec.ts @@ -0,0 +1,438 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; +import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; +import { + ExtensionMetadata, + ExtensionSite, + Site, + SiteId, + SiteMetadata, +} from "@bitwarden/common/tools/extension"; +import { ExtensionService } from "@bitwarden/common/tools/extension/extension.service"; +import { Bitwarden } from "@bitwarden/common/tools/extension/vendor/bitwarden"; +import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; +import { deepFreeze } from "@bitwarden/common/tools/util"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec"; +import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata"; +import catchall from "../metadata/email/catchall"; +import plusAddress from "../metadata/email/plus-address"; +import passphrase from "../metadata/password/eff-word-list"; +import password from "../metadata/password/random-password"; +import effWordList from "../metadata/username/eff-word-list"; +import { CredentialPreference } from "../types"; + +import { PREFERENCES } from "./credential-preferences"; +import { GeneratorMetadataProvider } from "./generator-metadata-provider"; + +const SomeUser = "some user" as UserId; +const SomeAccount = { + id: SomeUser, + email: "someone@example.com", + emailVerified: true, + name: "Someone", +}; +const SomeAccount$ = new BehaviorSubject(SomeAccount); + +const SomeEncryptor: UserEncryptor = { + userId: SomeUser, + + encrypt(secret) { + const tmp: any = secret; + return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any); + }, + + decrypt(secret) { + const tmp: any = JSON.parse(secret.encryptedString!); + return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any); + }, +}; + +const SomeAccountService = new FakeAccountService({ + [SomeUser]: SomeAccount, +}); + +const SomeStateProvider = new FakeStateProvider(SomeAccountService); + +const SystemProvider = { + encryptor: { + userEncryptor$: () => { + return new BehaviorSubject({ encryptor: SomeEncryptor, userId: SomeUser }).asObservable(); + }, + organizationEncryptor$() { + throw new Error("`organizationEncryptor$` should never be invoked."); + }, + } as LegacyEncryptorProvider, + state: SomeStateProvider, + log: disabledSemanticLoggerProvider, +} as UserStateSubjectDependencyProvider; + +const SomeSiteId: SiteId = Site.forwarder; + +const SomeSite: SiteMetadata = Object.freeze({ + id: SomeSiteId, + availableFields: [], +}); + +const SomePolicyService = mock(); + +const SomeExtensionService = mock(); + +const ApplicationProvider = { + /** Policy configured by the administrative console */ + policy: SomePolicyService, + + /** Client extension metadata and profile access */ + extension: SomeExtensionService, + + /** Event monitoring and diagnostic interfaces */ + log: disabledSemanticLoggerProvider, +} as SystemServiceProvider; + +describe("GeneratorMetadataProvider", () => { + beforeEach(() => { + jest.resetAllMocks(); + SomeExtensionService.site.mockImplementation(() => new ExtensionSite(SomeSite, new Map())); + }); + + describe("constructor", () => { + it("throws when the forwarder site isn't defined by the extension service", () => { + SomeExtensionService.site.mockReturnValue(undefined); + expect(() => new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [])).toThrow( + "forwarder extension site not found", + ); + }); + }); + + describe("metadata", () => { + it("returns algorithm metadata", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + password, + ]); + + const metadata = provider.metadata(password.id); + + expect(metadata).toEqual(password); + }); + + it("returns forwarder metadata", async () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const metadata = provider.metadata({ forwarder: Bitwarden.id }); + + expect(metadata.id).toEqual({ forwarder: Bitwarden.id }); + }); + + it("panics when metadata not found", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.metadata("not found" as any)).toThrow("metadata not found"); + }); + + it("panics when an extension not found", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.metadata({ forwarder: "not found" as any })).toThrow( + "extension not found", + ); + }); + }); + + describe("types", () => { + it("returns the credential types", async () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.types(); + + expect(result).toEqual(expect.arrayContaining(Types)); + }); + }); + + describe("algorithms", () => { + it("returns the password category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ type: Type.password }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.password])); + }); + + it("returns the username category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ type: Type.username }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.username])); + }); + + it("returns the email category's algorithms", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ type: Type.email }); + + expect(result).toEqual(expect.arrayContaining(AlgorithmsByType[Type.email])); + }); + + it("includes forwarder vendors in the email category's algorithms", () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const result = provider.algorithms({ type: Type.email }); + + expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }])); + }); + + it.each([ + [Algorithm.catchall], + [Algorithm.passphrase], + [Algorithm.password], + [Algorithm.plusAddress], + [Algorithm.username], + ])("returns explicit algorithms (=%p)", (algorithm) => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const result = provider.algorithms({ algorithm }); + + expect(result).toEqual([algorithm]); + }); + + it("returns explicit forwarders", () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + const result = provider.algorithms({ algorithm: { forwarder: Bitwarden.id } }); + + expect(result).toEqual(expect.arrayContaining([{ forwarder: Bitwarden.id }])); + }); + + it("returns an empty array when the algorithm is invalid", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + // `any` cast required because this test subverts the type system + const result = provider.algorithms({ algorithm: "an invalid algorithm" as any }); + + expect(result).toEqual([]); + }); + + it("returns an empty array when the forwarder is invalid", () => { + const extensionMetadata: ExtensionMetadata = { + site: SomeSite, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "https://www.example.com" }, + requestedFields: [], + }; + const application = { + ...ApplicationProvider, + extension: mock({ + site: () => new ExtensionSite(SomeSite, new Map([[Bitwarden.id, extensionMetadata]])), + }), + }; + const provider = new GeneratorMetadataProvider(SystemProvider, application, []); + + // `any` cast required because this test subverts the type system + const result = provider.algorithms({ + algorithm: { forwarder: "an invalid forwarder" as any }, + }); + + expect(result).toEqual([]); + }); + + it("panics when neither an algorithm nor a category is specified", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + // `any` cast required because this test subverts the type system + expect(() => provider.algorithms({} as any)).toThrow("algorithm or type required"); + }); + }); + + describe("algorithms$", () => { + it.each([ + [Algorithm.catchall, catchall], + [Algorithm.username, effWordList], + [Algorithm.password, password], + ])("gets a specific algorithm", async (algorithm, metadata) => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + metadata, + ]); + const result = new ReplaySubject(1); + + provider.algorithms$({ algorithm }, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toEqual([algorithm]); + }); + + it.each([ + [Type.email, [catchall, plusAddress]], + [Type.username, [effWordList]], + [Type.password, [password, passphrase]], + ])("gets a category of algorithms", async (category, metadata) => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata); + const result = new ReplaySubject(1); + + provider.algorithms$({ type: category }, { account$: SomeAccount$ }).subscribe(result); + + const expectedAlgorithms = expect.arrayContaining(metadata.map((m) => m.id)); + await expect(firstValueFrom(result)).resolves.toEqual(expectedAlgorithms); + }); + + it("omits algorithms blocked by policy", async () => { + const policy = new Policy({ + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + overridePasswordType: Algorithm.password, + }, + } as any); + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([policy])); + const metadata = [password, passphrase]; + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, metadata); + const algorithmResult = new ReplaySubject(1); + const categoryResult = new ReplaySubject(1); + + provider + .algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ }) + .subscribe(algorithmResult); + provider + .algorithms$({ type: Type.password }, { account$: SomeAccount$ }) + .subscribe(categoryResult); + + await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]); + await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]); + }); + + it("omits algorithms whose metadata is unavailable", async () => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + password, + ]); + const algorithmResult = new ReplaySubject(1); + const categoryResult = new ReplaySubject(1); + + provider + .algorithms$({ algorithm: Algorithm.passphrase }, { account$: SomeAccount$ }) + .subscribe(algorithmResult); + provider + .algorithms$({ type: Type.password }, { account$: SomeAccount$ }) + .subscribe(categoryResult); + + await expect(firstValueFrom(algorithmResult)).resolves.toEqual([]); + await expect(firstValueFrom(categoryResult)).resolves.toEqual([password.id]); + }); + + it("panics when neither algorithm nor category are specified", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + expect(() => provider.algorithms$({} as any, { account$: SomeAccount$ })).toThrow( + "algorithm or type required", + ); + }); + }); + + describe("preference$", () => { + const preferences: CredentialPreference = deepFreeze({ + [Type.email]: { algorithm: Algorithm.catchall, updated: new Date() }, + [Type.username]: { algorithm: Algorithm.username, updated: new Date() }, + [Type.password]: { algorithm: Algorithm.password, updated: new Date() }, + }); + beforeEach(async () => { + await SomeStateProvider.setUserState(PREFERENCES, preferences, SomeAccount.id); + }); + + it.each([ + [Type.email, catchall], + [Type.username, effWordList], + [Type.password, password], + ])("emits the user's %s preference", async (type, metadata) => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + metadata, + ]); + const result = new ReplaySubject(1); + + provider.preference$(type, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toEqual(preferences[type].algorithm); + }); + + it("emits a default when the user's preference is unavailable", async () => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, [ + plusAddress, + ]); + const result = new ReplaySubject(1); + + // precondition: the preferred email is excluded from the provided metadata + expect(preferences.email.algorithm).not.toEqual(plusAddress.id); + + provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toEqual(plusAddress.id); + }); + + it("emits undefined when the user's preference is unavailable and there is no metadata", async () => { + SomePolicyService.getAll$.mockReturnValue(new BehaviorSubject([])); + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + const result = new ReplaySubject(1); + + provider.preference$(Type.email, { account$: SomeAccount$ }).subscribe(result); + + await expect(firstValueFrom(result)).resolves.toBeUndefined(); + }); + }); + + describe("preferences", () => { + it("returns a user state subject", () => { + const provider = new GeneratorMetadataProvider(SystemProvider, ApplicationProvider, []); + + const subject = provider.preferences({ account$: SomeAccount$ }); + + expect(subject).toBeInstanceOf(UserStateSubject); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.ts new file mode 100644 index 00000000000..f8c07283f5a --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.ts @@ -0,0 +1,252 @@ +import { + Observable, + combineLatestWith, + distinctUntilChanged, + map, + shareReplay, + switchMap, + takeUntil, +} from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency } from "@bitwarden/common/tools/dependencies"; +import { ExtensionSite } from "@bitwarden/common/tools/extension"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { anyComplete, pin } from "@bitwarden/common/tools/rx"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; + +import { + GeneratorMetadata, + AlgorithmsByType, + CredentialAlgorithm, + CredentialType, + isForwarderExtensionId, + toForwarderMetadata, + Type, + Algorithms, + Types, +} from "../metadata"; +import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy"; +import { CredentialPreference } from "../types"; +import { + AlgorithmRequest, + TypeRequest, + MetadataRequest, + isAlgorithmRequest, + isTypeRequest, +} from "../types/metadata-request"; + +import { PREFERENCES } from "./credential-preferences"; + +/** Surfaces contextual information to credential generators */ +export class GeneratorMetadataProvider { + /** Instantiates the context provider + * @param system dependency providers for user state subjects + * @param application dependency providers for system services + */ + constructor( + private readonly system: UserStateSubjectDependencyProvider, + private readonly application: SystemServiceProvider, + algorithms: ReadonlyArray>, + ) { + this.log = system.log({ type: "GeneratorMetadataProvider" }); + + const site = application.extension.site("forwarder"); + if (!site) { + this.log.panic("forwarder extension site not found"); + } + this.site = site; + + this._metadata = new Map(algorithms.map((a) => [a.id, a] as const)); + } + + private readonly site: ExtensionSite; + private readonly log: SemanticLogger; + + private _metadata: Map>; + + /** Retrieve an algorithm's generator metadata + * @param algorithm identifies the algorithm + * @returns the algorithm's generator metadata + * @throws when the algorithm doesn't identify a known metadata entry + */ + metadata(algorithm: CredentialAlgorithm) { + let result = null; + if (isForwarderExtensionId(algorithm)) { + const extension = this.site.extensions.get(algorithm.forwarder); + if (!extension) { + this.log.panic(algorithm, "extension not found"); + } + + result = toForwarderMetadata(extension); + } else { + result = this._metadata.get(algorithm); + } + + if (!result) { + this.log.panic({ algorithm }, "metadata not found"); + } + + return result; + } + + /** retrieve credential types */ + types(): ReadonlyArray { + return Types; + } + + /** Retrieve the credential algorithm ids that match the request. + * @param requested when this has a `type` property, the method + * returns all algorithms with the same credential type. When this has an `algorithm` + * property, the method returns 0 or 1 matching algorithms. + * @returns the matching algorithms. This method always returns an array; + * the array is empty when no algorithms match the input criteria. + * @throws when neither `requested.algorithm` nor `requested.type` contains + * a value. + * @remarks this method enforces technical requirements only. + * If you want these algorithms with policy controls applied, use `algorithms$`. + */ + algorithms(requested: AlgorithmRequest): CredentialAlgorithm[]; + algorithms(requested: TypeRequest): CredentialAlgorithm[]; + algorithms(requested: MetadataRequest): CredentialAlgorithm[] { + let algorithms: CredentialAlgorithm[]; + if (isTypeRequest(requested)) { + let forwarders: CredentialAlgorithm[] = []; + if (requested.type === Type.email) { + forwarders = Array.from(this.site.extensions.keys()).map((forwarder) => ({ forwarder })); + } + + algorithms = AlgorithmsByType[requested.type].concat(forwarders); + } else if (isAlgorithmRequest(requested) && isForwarderExtensionId(requested.algorithm)) { + algorithms = this.site.extensions.has(requested.algorithm.forwarder) + ? [requested.algorithm] + : []; + } else if (isAlgorithmRequest(requested)) { + algorithms = Algorithms.includes(requested.algorithm) ? [requested.algorithm] : []; + } else { + this.log.panic(requested, "algorithm or type required"); + } + + return algorithms; + } + + // emits a function that returns `true` when the input algorithm is available + private isAvailable$( + dependencies: BoundDependency<"account", Account>, + ): Observable<(a: CredentialAlgorithm) => boolean> { + const id$ = dependencies.account$.pipe( + map((account) => account.id), + pin(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + const available$ = id$.pipe( + switchMap((id) => { + const policies$ = this.application.policy.getAll$(PolicyType.PasswordGenerator, id).pipe( + map((p) => availableAlgorithms_vNext(p).filter((a) => this._metadata.has(a))), + map((p) => new Set(p)), + // complete policy emissions otherwise `switchMap` holds `available$` open indefinitely + takeUntil(anyComplete(id$)), + ); + return policies$; + }), + map( + (available) => + function (a: CredentialAlgorithm) { + return isForwarderExtensionId(a) || available.has(a); + }, + ), + ); + + return available$; + } + + /** Retrieve credential algorithms filtered by the user's active policy. + * @param requested when this has a `type` property, the method + * returns all algorithms with a matching credential type. When this has an `algorithm` + * property, the method returns 0 or 1 matching algorithms. + * @param dependencies.account the account requesting algorithm access; + * this parameter controls which policy, if any, is applied. + * @returns an observable that emits matching algorithms. When no algorithms + * match the request, an empty array is emitted. + * @throws when neither `requested.algorithm` nor `requested.type` contains + * a value. + * @remarks this method applies policy controls. In particular, it excludes + * algorithms prohibited by a policy control. If you want lists of algorithms + * supported by the client, use `algorithms`. + */ + algorithms$( + requested: AlgorithmRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable; + algorithms$( + requested: TypeRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable; + algorithms$( + requested: MetadataRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable { + if (isTypeRequest(requested)) { + const { type } = requested; + return this.isAvailable$(dependencies).pipe( + map((isAvailable) => this.algorithms({ type }).filter(isAvailable)), + ); + } else if (isAlgorithmRequest(requested)) { + const { algorithm } = requested; + return this.isAvailable$(dependencies).pipe( + map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])), + ); + } else { + this.log.panic(requested, "algorithm or type required"); + } + } + + preference$(type: CredentialType, dependencies: BoundDependency<"account", Account>) { + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + + const algorithm$ = this.preferences({ account$ }).pipe( + combineLatestWith(this.isAvailable$({ account$ })), + map(([preferences, isAvailable]) => { + const algorithm: CredentialAlgorithm = preferences[type].algorithm; + if (isAvailable(algorithm)) { + return algorithm; + } + + const algorithms = type ? this.algorithms({ type: type }) : []; + // `?? null` because logging types must be `Jsonify` + const defaultAlgorithm = algorithms.find(isAvailable) ?? null; + this.log.debug( + { algorithm, defaultAlgorithm, credentialType: type }, + "preference not available; defaulting the generator algorithm", + ); + + // `?? undefined` so that interface is ADR-14 compliant + return defaultAlgorithm ?? undefined; + }), + distinctUntilChanged(), + ); + + return algorithm$; + } + + /** Get a subject bound to credential generator preferences. + * @param dependencies.account$ identifies the account to which the preferences are bound + * @returns a subject bound to the user's preferences + * @remarks Preferences determine which algorithms are used when generating a + * credential from a credential type (e.g. `PassX` or `Username`). Preferences + * should not be used to hold navigation history. Use @bitwarden/generator-navigation + * instead. + */ + preferences( + dependencies: BoundDependency<"account", Account>, + ): UserStateSubject { + // FIXME: enforce policy + const subject = new UserStateSubject(PREFERENCES, this.system, dependencies); + + return subject; + } +} diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 08aec48a9e7..36b0f3046a9 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -133,7 +133,9 @@ export type CredentialGeneratorConfiguration = CredentialGener }; /** Defines the stored parameters for credential generation */ settings: { - /** value used when an account's settings haven't been initialized */ + /** value used when an account's settings haven't been initialized + * @deprecated use `ObjectKey.initial` for your desired storage property instead + */ initial: Readonly>; /** Application-global constraints that apply to account settings */ diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 5b74d17fa4a..c75e4329610 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,6 +1,8 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationId } from "@bitwarden/common/tools/integration"; import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; +import { AlgorithmsByType, CredentialType } from "../metadata"; /** A type of password that may be generated by the credential generator. */ export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number]; @@ -11,7 +13,7 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number]; /** A type of email address that may be generated by the credential generator. */ export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; -export type ForwarderIntegration = { forwarder: IntegrationId }; +export type ForwarderIntegration = { forwarder: IntegrationId & VendorId }; /** Returns true when the input algorithm is a forwarder integration. */ export function isForwarderIntegration( @@ -74,8 +76,8 @@ export type CredentialCategory = keyof typeof CredentialCategories; /** The kind of credential to generate using a compound configuration. */ // FIXME: extend the preferences to include a preferred forwarder export type CredentialPreference = { - [Key in CredentialCategory]: { - algorithm: (typeof CredentialCategories)[Key][number]; + [Key in CredentialType & CredentialCategory]: { + algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number]; updated: Date; }; }; diff --git a/libs/tools/generator/core/src/types/metadata-request.ts b/libs/tools/generator/core/src/types/metadata-request.ts new file mode 100644 index 00000000000..e9cae7060f0 --- /dev/null +++ b/libs/tools/generator/core/src/types/metadata-request.ts @@ -0,0 +1,13 @@ +import { CredentialAlgorithm, CredentialType } from "../metadata"; + +export type AlgorithmRequest = { algorithm: CredentialAlgorithm }; +export type TypeRequest = { type: CredentialType }; +export type MetadataRequest = Partial; + +export function isAlgorithmRequest(request: MetadataRequest): request is AlgorithmRequest { + return !!request.algorithm; +} + +export function isTypeRequest(request: MetadataRequest): request is TypeRequest { + return !!request.type; +} From f3a26497520e4fccc3365965811dcb72256156db Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:34:43 -0500 Subject: [PATCH 013/228] refactor(auth): [PM-18148] replace app-link-sso directive with LinkSsoService Removes the app-link-sso directive and adds a LinkSsoService which is used to link an organization with SSO. Resolves PM-18148 --- apps/web/src/app/auth/core/services/index.ts | 1 + .../core/services/link-sso.service.spec.ts | 154 ++++++++++++++++++ .../auth/core/services/link-sso.service.ts | 91 +++++++++++ apps/web/src/app/core/core.module.ts | 13 ++ .../components/link-sso.directive.ts | 26 --- .../organization-options.component.html | 4 +- .../organization-options.component.ts | 33 +++- .../vault-filter/vault-filter.module.ts | 3 +- 8 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/app/auth/core/services/link-sso.service.spec.ts create mode 100644 apps/web/src/app/auth/core/services/link-sso.service.ts delete mode 100644 apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index 1e8eec759b1..11c8dd98872 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -4,3 +4,4 @@ export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; export * from "./two-factor-auth"; +export * from "./link-sso.service"; diff --git a/apps/web/src/app/auth/core/services/link-sso.service.spec.ts b/apps/web/src/app/auth/core/services/link-sso.service.spec.ts new file mode 100644 index 00000000000..70b52999875 --- /dev/null +++ b/apps/web/src/app/auth/core/services/link-sso.service.spec.ts @@ -0,0 +1,154 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + PasswordGenerationServiceAbstraction, + PasswordGeneratorOptions, +} from "@bitwarden/generator-legacy"; + +import { LinkSsoService } from "./link-sso.service"; + +describe("LinkSsoService", () => { + let sut: LinkSsoService; + + let mockSsoLoginService: MockProxy; + let mockApiService: MockProxy; + let mockCryptoFunctionService: MockProxy; + let mockEnvironmentService: MockProxy; + let mockPasswordGenerationService: MockProxy; + let mockPlatformUtilsService: MockProxy; + + const mockEnvironment$ = new BehaviorSubject({ + getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"), + }); + + beforeEach(() => { + // Create mock implementations + mockSsoLoginService = mock(); + mockApiService = mock(); + mockCryptoFunctionService = mock(); + mockEnvironmentService = mock(); + mockPasswordGenerationService = mock(); + mockPlatformUtilsService = mock(); + + // Set up environment service to return our mock environment + mockEnvironmentService.environment$ = mockEnvironment$; + + // Set up API service mocks + const mockResponse = { Token: "mockSsoToken" }; + mockApiService.preValidateSso.mockResolvedValue(new SsoPreValidateResponse(mockResponse)); + mockApiService.getSsoUserIdentifier.mockResolvedValue("mockUserIdentifier"); + + // Set up password generation service mock + mockPasswordGenerationService.generatePassword.mockImplementation( + async (options: PasswordGeneratorOptions) => { + return "mockGeneratedPassword"; + }, + ); + + // Set up crypto function service mock + mockCryptoFunctionService.hash.mockResolvedValue(new Uint8Array([1, 2, 3, 4])); + + // Create the service under test with mock dependencies + sut = new LinkSsoService( + mockSsoLoginService, + mockApiService, + mockCryptoFunctionService, + mockEnvironmentService, + mockPasswordGenerationService, + mockPlatformUtilsService, + ); + + // Mock Utils.fromBufferToUrlB64 + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue("mockCodeChallenge"); + + // Mock window.location + Object.defineProperty(window, "location", { + value: { + origin: "https://bitwarden.com", + }, + writable: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("linkSso", () => { + it("throws an error when identifier is null", async () => { + await expect(sut.linkSso(null as unknown as string)).rejects.toThrow( + "SSO identifier is required", + ); + }); + + it("throws an error when identifier is empty", async () => { + await expect(sut.linkSso("")).rejects.toThrow("SSO identifier is required"); + }); + + it("calls preValidateSso with the provided identifier", async () => { + await sut.linkSso("org123"); + + expect(mockApiService.preValidateSso).toHaveBeenCalledWith("org123"); + }); + + it("generates a password for code verifier", async () => { + await sut.linkSso("org123"); + + expect(mockPasswordGenerationService.generatePassword).toHaveBeenCalledWith({ + type: "password", + length: 64, + uppercase: true, + lowercase: true, + number: true, + special: false, + }); + }); + + it("sets the code verifier in the ssoLoginService", async () => { + await sut.linkSso("org123"); + + expect(mockSsoLoginService.setCodeVerifier).toHaveBeenCalledWith("mockGeneratedPassword"); + }); + + it("generates a state and sets it in the ssoLoginService", async () => { + await sut.linkSso("org123"); + + const expectedState = + "mockGeneratedPassword_returnUri='/settings/organizations'_identifier=org123"; + expect(mockSsoLoginService.setSsoState).toHaveBeenCalledWith(expectedState); + }); + + it("gets the SSO user identifier from the API", async () => { + await sut.linkSso("org123"); + + expect(mockApiService.getSsoUserIdentifier).toHaveBeenCalled(); + }); + + it("launches the authorize URL with the correct parameters", async () => { + await sut.linkSso("org123"); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + expect.stringContaining("https://identity.bitwarden.com/connect/authorize"), + { sameWindow: true }, + ); + + const launchUriArg = mockPlatformUtilsService.launchUri.mock.calls[0][0]; + expect(launchUriArg).toContain("client_id=web"); + expect(launchUriArg).toContain( + "redirect_uri=https%3A%2F%2Fbitwarden.com%2Fsso-connector.html", + ); + expect(launchUriArg).toContain("response_type=code"); + expect(launchUriArg).toContain("code_challenge=mockCodeChallenge"); + expect(launchUriArg).toContain("ssoToken=mockSsoToken"); + expect(launchUriArg).toContain("user_identifier=mockUserIdentifier"); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/link-sso.service.ts b/apps/web/src/app/auth/core/services/link-sso.service.ts new file mode 100644 index 00000000000..3d51525add1 --- /dev/null +++ b/apps/web/src/app/auth/core/services/link-sso.service.ts @@ -0,0 +1,91 @@ +import { firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + PasswordGenerationServiceAbstraction, + PasswordGeneratorOptions, +} from "@bitwarden/generator-legacy"; + +/** + * Provides a service for linking SSO. + */ +export class LinkSsoService { + constructor( + private ssoLoginService: SsoLoginServiceAbstraction, + private apiService: ApiService, + private cryptoFunctionService: CryptoFunctionService, + private environmentService: EnvironmentService, + private passwordGenerationService: PasswordGenerationServiceAbstraction, + private platformUtilsService: PlatformUtilsService, + ) {} + + /** + * Links SSO to an organization. + * Ported from the SsoComponent + * @param identifier The identifier of the organization to link to. + */ + async linkSso(identifier: string) { + if (identifier == null || identifier === "") { + throw new Error("SSO identifier is required"); + } + + const redirectUri = window.location.origin + "/sso-connector.html"; + const clientId = "web"; + const returnUri = "/settings/organizations"; + + const response = await this.apiService.preValidateSso(identifier); + + const passwordOptions: PasswordGeneratorOptions = { + type: "password", + length: 64, + uppercase: true, + lowercase: true, + number: true, + special: false, + }; + + const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + await this.ssoLoginService.setCodeVerifier(codeVerifier); + + let state = await this.passwordGenerationService.generatePassword(passwordOptions); + state += `_returnUri='${returnUri}'`; + state += `_identifier=${identifier}`; + + // Save state + await this.ssoLoginService.setSsoState(state); + + const env = await firstValueFrom(this.environmentService.environment$); + + let authorizeUrl = + env.getIdentityUrl() + + "/connect/authorize?" + + "client_id=" + + clientId + + "&redirect_uri=" + + encodeURIComponent(redirectUri) + + "&" + + "response_type=code&scope=api offline_access&" + + "state=" + + state + + "&code_challenge=" + + codeChallenge + + "&" + + "code_challenge_method=S256&response_mode=query&" + + "domain_hint=" + + encodeURIComponent(identifier) + + "&ssoToken=" + + encodeURIComponent(response.token); + + const userIdentifier = await this.apiService.getSsoUserIdentifier(); + authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`; + + this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true }); + } +} diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index cc9024490d6..9e6f88d18d6 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -116,6 +116,7 @@ import { WebLoginDecryptionOptionsService, WebTwoFactorAuthComponentService, WebTwoFactorAuthDuoComponentService, + LinkSsoService, } from "../auth"; import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; @@ -345,6 +346,18 @@ const safeProviders: SafeProvider[] = [ useClass: WebSsoComponentService, deps: [I18nServiceAbstraction], }), + safeProvider({ + provide: LinkSsoService, + useClass: LinkSsoService, + deps: [ + SsoLoginServiceAbstraction, + ApiService, + CryptoFunctionService, + EnvironmentService, + PasswordGenerationServiceAbstraction, + PlatformUtilsService, + ], + }), safeProvider({ provide: TwoFactorAuthDuoComponentService, useClass: WebTwoFactorAuthDuoComponentService, diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts deleted file mode 100644 index a1781889c49..00000000000 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/link-sso.directive.ts +++ /dev/null @@ -1,26 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { AfterContentInit, Directive, HostListener, Input } from "@angular/core"; - -import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - -@Directive({ - selector: "[app-link-sso]", -}) -export class LinkSsoDirective extends SsoComponent implements AfterContentInit { - @Input() organization: Organization; - returnUri = "/settings/organizations"; - redirectUri = window.location.origin + "/sso-connector.html"; - clientId = "web"; - - @HostListener("click", ["$event"]) - async onClick($event: MouseEvent) { - $event.preventDefault(); - await this.submit(this.returnUri, true); - } - - async ngAfterContentInit() { - this.identifier = this.organization.identifier; - } -} diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html index 0b94b6e2be2..0fe243ed20a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.html @@ -50,10 +50,10 @@ {{ "unlinkSso" | i18n }} - + From 6d4179052a318655f26de0408c82fe0563ef5295 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 28 Mar 2025 12:22:38 +0100 Subject: [PATCH 036/228] Autosync the updated translations (#14038) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/src/locales/af/messages.json | 9 +++++++++ apps/desktop/src/locales/ar/messages.json | 9 +++++++++ apps/desktop/src/locales/az/messages.json | 9 +++++++++ apps/desktop/src/locales/be/messages.json | 9 +++++++++ apps/desktop/src/locales/bg/messages.json | 9 +++++++++ apps/desktop/src/locales/bn/messages.json | 9 +++++++++ apps/desktop/src/locales/bs/messages.json | 9 +++++++++ apps/desktop/src/locales/ca/messages.json | 9 +++++++++ apps/desktop/src/locales/cs/messages.json | 9 +++++++++ apps/desktop/src/locales/cy/messages.json | 9 +++++++++ apps/desktop/src/locales/da/messages.json | 9 +++++++++ apps/desktop/src/locales/de/messages.json | 9 +++++++++ apps/desktop/src/locales/el/messages.json | 9 +++++++++ apps/desktop/src/locales/en_GB/messages.json | 9 +++++++++ apps/desktop/src/locales/en_IN/messages.json | 9 +++++++++ apps/desktop/src/locales/eo/messages.json | 9 +++++++++ apps/desktop/src/locales/es/messages.json | 9 +++++++++ apps/desktop/src/locales/et/messages.json | 9 +++++++++ apps/desktop/src/locales/eu/messages.json | 9 +++++++++ apps/desktop/src/locales/fa/messages.json | 9 +++++++++ apps/desktop/src/locales/fi/messages.json | 9 +++++++++ apps/desktop/src/locales/fil/messages.json | 9 +++++++++ apps/desktop/src/locales/fr/messages.json | 9 +++++++++ apps/desktop/src/locales/gl/messages.json | 9 +++++++++ apps/desktop/src/locales/he/messages.json | 9 +++++++++ apps/desktop/src/locales/hi/messages.json | 9 +++++++++ apps/desktop/src/locales/hr/messages.json | 9 +++++++++ apps/desktop/src/locales/hu/messages.json | 9 +++++++++ apps/desktop/src/locales/id/messages.json | 9 +++++++++ apps/desktop/src/locales/it/messages.json | 9 +++++++++ apps/desktop/src/locales/ja/messages.json | 11 ++++++++++- apps/desktop/src/locales/ka/messages.json | 9 +++++++++ apps/desktop/src/locales/km/messages.json | 9 +++++++++ apps/desktop/src/locales/kn/messages.json | 9 +++++++++ apps/desktop/src/locales/ko/messages.json | 9 +++++++++ apps/desktop/src/locales/lt/messages.json | 9 +++++++++ apps/desktop/src/locales/lv/messages.json | 11 ++++++++++- apps/desktop/src/locales/me/messages.json | 9 +++++++++ apps/desktop/src/locales/ml/messages.json | 9 +++++++++ apps/desktop/src/locales/mr/messages.json | 9 +++++++++ apps/desktop/src/locales/my/messages.json | 9 +++++++++ apps/desktop/src/locales/nb/messages.json | 9 +++++++++ apps/desktop/src/locales/ne/messages.json | 9 +++++++++ apps/desktop/src/locales/nl/messages.json | 9 +++++++++ apps/desktop/src/locales/nn/messages.json | 9 +++++++++ apps/desktop/src/locales/or/messages.json | 9 +++++++++ apps/desktop/src/locales/pl/messages.json | 9 +++++++++ apps/desktop/src/locales/pt_BR/messages.json | 9 +++++++++ apps/desktop/src/locales/pt_PT/messages.json | 9 +++++++++ apps/desktop/src/locales/ro/messages.json | 9 +++++++++ apps/desktop/src/locales/ru/messages.json | 9 +++++++++ apps/desktop/src/locales/si/messages.json | 9 +++++++++ apps/desktop/src/locales/sk/messages.json | 9 +++++++++ apps/desktop/src/locales/sl/messages.json | 9 +++++++++ apps/desktop/src/locales/sr/messages.json | 9 +++++++++ apps/desktop/src/locales/sv/messages.json | 9 +++++++++ apps/desktop/src/locales/te/messages.json | 9 +++++++++ apps/desktop/src/locales/th/messages.json | 9 +++++++++ apps/desktop/src/locales/tr/messages.json | 9 +++++++++ apps/desktop/src/locales/uk/messages.json | 9 +++++++++ apps/desktop/src/locales/vi/messages.json | 9 +++++++++ apps/desktop/src/locales/zh_CN/messages.json | 9 +++++++++ apps/desktop/src/locales/zh_TW/messages.json | 9 +++++++++ 63 files changed, 569 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index ae324eda6bc..e288e9e70cc 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 9f5b1f3501c..7de8ecb50f5 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "تصدير خزانة المؤسسة" }, diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index b7c7cb3cd67..738944f1791 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Yalnız $EMAIL$ ilə əlaqələndirilmiş qoşmalar daxil olmaqla fərdi anbar elementləri xaricə köçürüləcək. Təşkilat anbar elementləri daxil edilməyəcək", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Təşkilat seyfini xaricə köçürmə" }, diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 4c90222be42..ef73dcb3ed3 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 6a1c0983278..c9bae830eff 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Ще бъдат изнесени само записите и прикачените файлове от личния трезор свързан с $EMAIL$. Записите в трезора на организацията няма да бъдат включени.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Изнасяне на трезора на организацията" }, diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 68848c9ef2a..440462cf5d3 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 885af404f95..4fb9697a2c9 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 5707fd0c0ff..dbba24a9050 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "S'està exportant la caixa forta de l’organització" }, diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 336ea2a795c..c4d61da936b 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Budou exportovány jen osobní položky trezoru včetně příloh spojené s účtem $EMAIL$. Nebudou zahrnuty položky trezoru v organizaci.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportování trezoru organizace" }, diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 1c6d824c22e..5f06fd762f4 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index ef5b22e33f4..e11aef18e14 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Eksport af organisationsboks" }, diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 79f61475ab0..b28c2fced1d 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Tresor der Organisation wird exportiert" }, diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index ecee8bd9894..9c59be2e0a7 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Εξαγωγή θησαυ/κίου οργανισμού" }, diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index f70a70e975a..74e43b24076 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organisation vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organisation vault" }, diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index f91a7b21876..3ec9e17769b 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organisation vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organisation vault" }, diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 200c3f4a1de..2dfdcdd9484 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 665721beb3a..6936753ebb2 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportando caja fuerte de la organización" }, diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index c4ca9bef886..20650145478 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Ekspordin organisatsiooni hoidlat" }, diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 1abc24612ca..57763cc62c1 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index 2d13b86c4a8..eecbd6dcb85 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 2da2b7dcf7a..8964e7cf982 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Organisaation holvin vienti" }, diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index d54c5a21a87..ec1a2d0e281 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 431a70ef373..750b91adf57 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Export du coffre-fort de l'organisation" }, diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index f93db44aa69..07404b37fcd 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 7f98ab977ae..34db4dd53ba 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "מייצא כספת ארגון" }, diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index 90fed49de76..7cfbb08a7d4 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index cb66437bedd..989faae1759 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Izvest će se samo stavke i privici osobnog trezora povezanog s $EMAIL$. Stavke organizacijskog trezora neće biti uključene", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Izvoz organizacijskog trezora" }, diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index b9573428136..775ce2dd80c 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Csak $EMAIL$ email címmel társított személyes széf elemek kerülnek exportálásra. Ebbe nem kerülnek be a szervezeti széf elemek.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Szervezeti széf exportálása" }, diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index df9b6484778..0c46ff5e5ea 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index ef5fa5ce381..f97b3135c9f 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Esportando cassaforte dell'organizzazione" }, diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index e8ba0e8509b..58c4d866e42 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "$EMAIL$ に関連付けられた個人用保管庫のアイテムのみが、添付ファイルを含めてエクスポートされます。組織用保管庫のアイテムは含まれません。", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "組織保管庫のエクスポート" }, @@ -3539,7 +3548,7 @@ "message": "後で再通知" }, "newDeviceVerificationNoticePageOneFormContent": { - "message": "新しいメールアドレス $EMAIL$ はあなたが管理しているものですか?", + "message": "メールアドレス $EMAIL$ は、確実にアクセスできるものですか?", "placeholders": { "email": { "content": "$1", diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 96962e14ff5..3eb666c7d44 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index f93db44aa69..07404b37fcd 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index f9bb154b66e..c530ab799f7 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 44bc681f205..e5df2555531 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 40436c7ccda..7cf1bc463b3 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index c519987fc72..b3fa396d811 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -1706,7 +1706,7 @@ "message": "Apstiprināt glabātavas satura izgūšanu" }, "exportWarningDesc": { - "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izdoto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Tā ir jāizdzēš uzreiz pēc izmantošanas." + "message": "Šī izguve satur glabātavas datus nešifrētā veidā. Izgūto datni nevajadzētu glabāt vai sūtīt nedrošos veidos (piemēram, e-pastā). Tā ir jāizdzēš uzreiz pēc izmantošanas." }, "encExportKeyWarningDesc": { "message": "Šī izguve šifrē datus ar konta šifrēšanas atslēgu. Ja tā jebkad tiks mainīta, izvadi vajadzētu veikt vēlreiz, jo vairs nebūs iespējams atšifrēt šo datni." @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Tiks izdoti tikai atsevišķi glabātavas vienumi, tajā skaitā pielikumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Izgūst apvienības glabātavu" }, diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index ea8defdc07c..99944c6e10c 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index a4af9251191..79d36b99526 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index f93db44aa69..07404b37fcd 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 493d6fdb5b9..8aa0327203b 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index caf4d3da64b..d96eb92efb2 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index 297708953df..79aa89aed90 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index 1cfd5ff5523..29dddbf0f08 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Exporteert alleen de persoonlijke kluis-items, inclusief attachments, gerelateerd aan $EMAIL$. Geen kluis-items van de organisatie", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Organisatiekluis exporteren" }, diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index 9195311cc67..753b3ba5524 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 6f15ed21991..b5c3d4eab46 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index ef8cdc6eb9b..a8b5205a9d9 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Eksportowanie sejfu organizacji" }, diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index e09ee6e16d2..539d254344d 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportando cofre da organização" }, diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 2261357ba08..28ac0c82486 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Apenas os itens individuais do cofre, incluindo os anexos associados a $EMAIL$, serão exportados. Os itens do cofre da organização não serão incluídos", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "A exportar o cofre da organização" }, diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index d148d90a4fd..b160bfc14e3 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index ea5b8c6d526..73f52200e04 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Будут экспортированы только отдельные элементы хранилища, включая вложения, связанные с $EMAIL$. Элементы хранилища организации включены не будут", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Экспорт хранилища организации" }, diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index cb837ea4f76..e2f23c7bc56 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 7eaa1026c00..0f32c9945ce 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Exportované budú iba položky osobného trezora spojené s $EMAIL$. Položky trezora organizácie nebudú zahrnuté v exporte", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportovanie trezora organizácie" }, diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 3987587828a..4d50b4d9def 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 69b45469dc8..561e3d35ec2 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Извоз сефа организације" }, diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 1303243a8cc..f33339ea80d 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index f93db44aa69..07404b37fcd 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index cb9df027ad7..2bec157ec15 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 55b3b6059b7..974d27f4735 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Yalnızca $EMAIL$ ile ilişkili kişisel kasadaki kayıtlar ve dosyalar dışa aktarılacaktır. Kuruluş kasasındaki kayıtlar dahil edilmeyecektir", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Kuruluş kasasını dışa aktarma" }, diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index d5f908e011c..6fb5c564f3a 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Експортування сховища організації" }, diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index f6495e4f915..8b5f3250b85 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Đang xuất dữ liệu kho tổ chức" }, diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 6a12138b48f..e85b2606535 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "仅会导出与 $EMAIL$ 关联的个人密码库项目(包括附件)。不包括组织密码库项目。", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "正在导出组织密码库" }, diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 5da6c4a0b6e..ab09790dcd0 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -2490,6 +2490,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "正匯出組織密碼庫" }, From 251a08fd93c4c5526f1c2d6e398633d70fcca221 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 28 Mar 2025 12:31:57 +0100 Subject: [PATCH 037/228] Autosync the updated translations (#14037) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/ar/messages.json | 37 ++++ apps/browser/src/_locales/az/messages.json | 37 ++++ apps/browser/src/_locales/be/messages.json | 53 ++++- apps/browser/src/_locales/bg/messages.json | 37 ++++ apps/browser/src/_locales/bn/messages.json | 37 ++++ apps/browser/src/_locales/bs/messages.json | 37 ++++ apps/browser/src/_locales/ca/messages.json | 37 ++++ apps/browser/src/_locales/cs/messages.json | 37 ++++ apps/browser/src/_locales/cy/messages.json | 37 ++++ apps/browser/src/_locales/da/messages.json | 37 ++++ apps/browser/src/_locales/de/messages.json | 37 ++++ apps/browser/src/_locales/el/messages.json | 37 ++++ apps/browser/src/_locales/en_GB/messages.json | 37 ++++ apps/browser/src/_locales/en_IN/messages.json | 37 ++++ apps/browser/src/_locales/es/messages.json | 37 ++++ apps/browser/src/_locales/et/messages.json | 37 ++++ apps/browser/src/_locales/eu/messages.json | 37 ++++ apps/browser/src/_locales/fa/messages.json | 37 ++++ apps/browser/src/_locales/fi/messages.json | 37 ++++ apps/browser/src/_locales/fil/messages.json | 37 ++++ apps/browser/src/_locales/fr/messages.json | 37 ++++ apps/browser/src/_locales/gl/messages.json | 37 ++++ apps/browser/src/_locales/he/messages.json | 37 ++++ apps/browser/src/_locales/hi/messages.json | 37 ++++ apps/browser/src/_locales/hr/messages.json | 37 ++++ apps/browser/src/_locales/hu/messages.json | 37 ++++ apps/browser/src/_locales/id/messages.json | 193 +++++++++++------- apps/browser/src/_locales/it/messages.json | 37 ++++ apps/browser/src/_locales/ja/messages.json | 39 +++- apps/browser/src/_locales/ka/messages.json | 37 ++++ apps/browser/src/_locales/km/messages.json | 37 ++++ apps/browser/src/_locales/kn/messages.json | 37 ++++ apps/browser/src/_locales/ko/messages.json | 37 ++++ apps/browser/src/_locales/lt/messages.json | 37 ++++ apps/browser/src/_locales/lv/messages.json | 37 ++++ apps/browser/src/_locales/ml/messages.json | 37 ++++ apps/browser/src/_locales/mr/messages.json | 37 ++++ apps/browser/src/_locales/my/messages.json | 37 ++++ apps/browser/src/_locales/nb/messages.json | 37 ++++ apps/browser/src/_locales/ne/messages.json | 37 ++++ apps/browser/src/_locales/nl/messages.json | 37 ++++ apps/browser/src/_locales/nn/messages.json | 37 ++++ apps/browser/src/_locales/or/messages.json | 37 ++++ apps/browser/src/_locales/pl/messages.json | 37 ++++ apps/browser/src/_locales/pt_BR/messages.json | 37 ++++ apps/browser/src/_locales/pt_PT/messages.json | 37 ++++ apps/browser/src/_locales/ro/messages.json | 37 ++++ apps/browser/src/_locales/ru/messages.json | 37 ++++ apps/browser/src/_locales/si/messages.json | 37 ++++ apps/browser/src/_locales/sk/messages.json | 37 ++++ apps/browser/src/_locales/sl/messages.json | 37 ++++ apps/browser/src/_locales/sr/messages.json | 37 ++++ apps/browser/src/_locales/sv/messages.json | 37 ++++ apps/browser/src/_locales/te/messages.json | 37 ++++ apps/browser/src/_locales/th/messages.json | 37 ++++ apps/browser/src/_locales/tr/messages.json | 37 ++++ apps/browser/src/_locales/uk/messages.json | 37 ++++ apps/browser/src/_locales/vi/messages.json | 37 ++++ apps/browser/src/_locales/zh_CN/messages.json | 37 ++++ apps/browser/src/_locales/zh_TW/messages.json | 37 ++++ 60 files changed, 2307 insertions(+), 87 deletions(-) diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 4c051d455ac..bf22a38be08 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 6f3ace453ae..1ca7642eb19 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Yalnız $EMAIL$ ilə əlaqələndirilmiş qoşmalar daxil olmaqla fərdi anbar elementləri xaricə köçürüləcək. Təşkilat anbar elementləri daxil edilməyəcək", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Təşkilat seyfini xaricə köçürmə" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Avto-doldur - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopyala: $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 905f6b6ac1d..a9a237aeb99 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -23,7 +23,7 @@ "message": "Упершыню ў Bitwarden?" }, "logInWithPasskey": { - "message": "Log in with passkey" + "message": "Увайсці з ключом доступу" }, "useSingleSignOn": { "message": "Выкарыстаць аднаразовы ўваход" @@ -35,7 +35,7 @@ "message": "Прызначыць надзейны пароль" }, "finishCreatingYourAccountBySettingAPassword": { - "message": "Finish creating your account by setting a password" + "message": "Завяршыць стварэнне вашага ўліковага запісу нарадзіўшы пароль" }, "enterpriseSingleSignOn": { "message": "Адзіны ўваход прадпрыемства (SSO)" @@ -62,7 +62,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", @@ -81,7 +81,7 @@ "message": "Падказка да асноўнага пароля (неабавязкова)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "Ацэнка надзейнасці пароля $SCORE$", "placeholders": { "score": { "content": "$1", @@ -206,10 +206,10 @@ "message": "Аўтазапаўненне асабістых даных" }, "fillVerificationCode": { - "message": "Fill verification code" + "message": "Запоўніць праверачны код" }, "fillVerificationCodeAria": { - "message": "Fill Verification Code", + "message": "Запоўніць праверачны код", "description": "Aria label for the heading displayed the inline menu for totp code autofill" }, "generatePasswordCopied": { @@ -261,7 +261,7 @@ "message": "Запытаць падказку да асноўнага пароля" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { - "message": "Enter your account email address and your password hint will be sent to you" + "message": "Увядзіце адрас электроннай пошты ўліковага запісу і падказка пароля будзе адпраўлена вам" }, "getMasterPasswordHint": { "message": "Атрымаць падказку да асноўнага пароля" @@ -291,7 +291,7 @@ "message": "Працягнуць у вэб-праграме?" }, "continueToWebAppDesc": { - "message": "Explore more features of your Bitwarden account on the web app." + "message": "Даследуйце больш функцый вашага уліковага запісу Bitwarden у вэб-праграме." }, "continueToHelpCenter": { "message": "Працягнуць працу ў Даведачным цэнтры?" @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Экспартаванне сховішча арганізацыі" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 226e63e32cb..c5dd0237a2c 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Ще бъдат изнесени само записите и прикачените файлове от личния трезор свързан с $EMAIL$. Записите в трезора на организацията няма да бъдат включени.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Изнасяне на трезора на организацията" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Авт. попълване – $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Копиране на $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 983b9fadde4..21a455265e4 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 08fedb6f10a..a2457c94080 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 14e4a577440..d16a679824d 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Només s'exportaran els elements personals incloent adjunts de la caixa forta associats a $EMAIL$. Els elements de la caixa forta de l'organització no s'inclouran", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "S'està exportant la caixa forta de l’organització" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index e106d371d57..e6bf4a728e4 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Budou exportovány jen osobní položky trezoru včetně příloh spojené s účtem $EMAIL$. Nebudou zahrnuty položky trezoru v organizaci.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportování trezoru organizace" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Zobrazit položku - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Automatické vyplnění - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Automatické vyplnění - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopírovat $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 83d09d13273..5ccff5a8332 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 69c8b28d29a..f66f8f34495 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Eksport af organisationsboks" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autoudfyld - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index a7439db6432..25e8b53cdb5 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Tresor der Organisation wird exportiert" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Auto-Ausfüllen - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "$FIELD$, $VALUE$ kopieren", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index fc07f12a48f..47dca3701ec 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Εξαγωγή θησαυ/κίου οργανισμού" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Αυτόματη συμπλήρωση - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 1381afbdc6e..9c6d212f8c9 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organisation vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organisation vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Auto-fill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 6b14a358148..aa7a234246f 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organisation vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organisation vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Auto-fill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 4d430d23337..d282011e628 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportando caja fuerte de la organización" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autocompletar - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 2daf5f9d7f0..e2ad9a1e53a 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index b1e1ca3526b..95d843a3aa8 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 2b3d66ad104..0f5616cd001 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index a37bd0235c1..ff71b93a62d 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Organisaation holvin vienti" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Automaattitäytä - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopioi $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 9f991843f4f..292c5fd0576 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 32b6dd0296b..5ec06f52a76 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Export du coffre de l'organisation" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Saisie automatique - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copier $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 6c0b9cce87b..c76c60114c9 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportar Caixa forte da organización" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autoenchido - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 035bf9da48e..0d15c90c3d1 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "מייצא כספת ארגון" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "מילוי אוטומטי - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "העתק $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 748d9eb966b..6dc3dced829 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "केवल $EMAIL$ से जुड़े अनुलग्नकों सहित व्यक्तिगत वॉल्ट आइटम ही निर्यात किए जाएंगे. संगठन वॉल्ट आइटम शामिल नहीं किए जाएंगे", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index b88fc45493f..eda6d7267a3 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Izvest će se samo stavke i privici osobnog trezora povezanog s $EMAIL$. Stavke organizacijskog trezora neće biti uključene", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Izvoz organizacijskog trezora" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Auto-ispuna - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopiraj $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index 80b1dbf095d..ec313c2cd10 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Csak $EMAIL$ email címmel társított személyes széf elemek kerülnek exportálásra. Ebbe nem kerülnek be a szervezeti széf elemek.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Szervezeti széf exportálása" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Automatikus kitöltés - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "$FIELD$, $VALUE$ másolása", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 0146fb2a000..daccc3d8272 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -81,7 +81,7 @@ "message": "Petunjuk Kata Sandi Utama (opsional)" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "Skor kekuatan kata sandi $SCORE$", "placeholders": { "score": { "content": "$1", @@ -656,10 +656,10 @@ "message": "Verifikasikan identitas Anda" }, "weDontRecognizeThisDevice": { - "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." + "message": "Kami tidak mengenali perangkat ini. Masukkan kode yang dikirim ke surel Anda untuk memverifikasi identitas Anda." }, "continueLoggingIn": { - "message": "Continue logging in" + "message": "Lanjutkan log masuk" }, "yourVaultIsLocked": { "message": "Brankas Anda terkunci. Verifikasi kata sandi utama Anda untuk melanjutkan." @@ -869,19 +869,19 @@ "message": "Masuk ke Bitwarden" }, "enterTheCodeSentToYourEmail": { - "message": "Enter the code sent to your email" + "message": "Masukkan kode yang dikirim ke surel Anda" }, "enterTheCodeFromYourAuthenticatorApp": { - "message": "Enter the code from your authenticator app" + "message": "Masukkan kode dari aplikasi autentikator Anda" }, "pressYourYubiKeyToAuthenticate": { - "message": "Press your YubiKey to authenticate" + "message": "Sentuh YubiKey Anda untuk mengautentikasi" }, "duoTwoFactorRequiredPageSubtitle": { - "message": "Duo two-step login is required for your account. Follow the steps below to finish logging in." + "message": "Log masuk dua langkah berganda diperlukan bagi akun Anda. Ikuti langkah di bawah untuk menyelesaikan log masuk." }, "followTheStepsBelowToFinishLoggingIn": { - "message": "Follow the steps below to finish logging in." + "message": "Ikuti langkah-langkah di bawah untuk menyelesaikan log masuk." }, "restartRegistration": { "message": "Mulai ulang pendaftaran" @@ -1019,7 +1019,7 @@ "message": "Tanyakan untuk menambah sebuah benda jika benda itu tidak ditemukan di brankas Anda. Diterapkan ke seluruh akun yang telah masuk." }, "showCardsInVaultViewV2": { - "message": "Always show cards as Autofill suggestions on Vault view" + "message": "Selalu tampilan kartu sebagai saran isi otomatis pada tampilan Brankas" }, "showCardsCurrentTab": { "message": "Tamplikan kartu pada halaman Tab" @@ -1028,7 +1028,7 @@ "message": "Buat tampilan daftar benda dari kartu pada halaman Tab untuk isi otomatis yang mudah." }, "showIdentitiesInVaultViewV2": { - "message": "Always show identities as Autofill suggestions on Vault view" + "message": "Selalu tampilan identitas sebagai saran isi otomatis pada tampilan Brankas" }, "showIdentitiesCurrentTab": { "message": "Tampilkan identitas pada halaman Tab" @@ -1037,10 +1037,10 @@ "message": "Buat tampilan daftar benda dari identitas pada halaman Tab untuk isi otomatis yang mudah." }, "clickToAutofillOnVault": { - "message": "Click items to autofill on Vault view" + "message": "Klik butir untuk mengisi otomatis pada tampilan Brankas" }, "clickToAutofill": { - "message": "Click items in autofill suggestion to fill" + "message": "Klik butir dalam saran isi otomatis untuk mengisi" }, "clearClipboard": { "message": "Hapus Papan Klip", @@ -1057,7 +1057,7 @@ "message": "Iya, Simpan Sekarang" }, "loginSaveSuccessDetails": { - "message": "$USERNAME$ saved to Bitwarden.", + "message": "$USERNAME$ disimpan ke Bitwarden.", "placeholders": { "username": { "content": "$1" @@ -1066,7 +1066,7 @@ "description": "Shown to user after login is saved." }, "loginUpdatedSuccessDetails": { - "message": "$USERNAME$ updated in Bitwarden.", + "message": "$USERNAME$ diperbarui di Bitwarden.", "placeholders": { "username": { "content": "$1" @@ -1075,35 +1075,35 @@ "description": "Shown to user after login is updated." }, "saveAsNewLoginAction": { - "message": "Save as new login", + "message": "Simpan sebagai log masuk baru", "description": "Button text for saving login details as a new entry." }, "updateLoginAction": { - "message": "Update login", + "message": "Perbarui log masuk", "description": "Button text for updating an existing login entry." }, "saveLoginPrompt": { - "message": "Save login?", + "message": "Simpan log masuk?", "description": "Prompt asking the user if they want to save their login details." }, "updateLoginPrompt": { - "message": "Update existing login?", + "message": "Perbarui log masuk yang ada?", "description": "Prompt asking the user if they want to update an existing login entry." }, "loginSaveSuccess": { - "message": "Login saved", + "message": "Log masuk disimpan", "description": "Message displayed when login details are successfully saved." }, "loginUpdateSuccess": { - "message": "Login updated", + "message": "Log masuk diperbarui", "description": "Message displayed when login details are successfully updated." }, "saveFailure": { - "message": "Error saving", + "message": "Kesalahan saat menyimpan", "description": "Error message shown when the system fails to save login details." }, "saveFailureDetails": { - "message": "Oh no! We couldn't save this. Try entering the details manually.", + "message": "Oh tidak! Kami tidak bisa menyimpan ini. Cobalah memasukkan rincian secara manual.", "description": "Detailed error message shown when saving login details fails." }, "enableChangedPasswordNotification": { @@ -1422,7 +1422,7 @@ "message": "Ingat saya" }, "dontAskAgainOnThisDeviceFor30Days": { - "message": "Don't ask again on this device for 30 days" + "message": "Jangan tanyakan lagi pada perangkat ini untuk 30 hari" }, "sendVerificationCodeEmailAgain": { "message": "Kirim ulang email kode verifikasi" @@ -1431,11 +1431,11 @@ "message": "Gunakan metode masuk dua langkah lainnya" }, "selectAnotherMethod": { - "message": "Select another method", + "message": "Pilih metode lain", "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Use your recovery code" + "message": "Gunakan kode pemulihan Anda" }, "insertYubiKey": { "message": "Masukkan YubiKey Anda ke port USB komputer Anda, lalu sentuh tombolnya." @@ -1450,16 +1450,16 @@ "message": "Buka tab baru" }, "openInNewTab": { - "message": "Open in new tab" + "message": "Buka dalam tab baru" }, "webAuthnAuthenticate": { "message": "Autentikasi dengan WebAuthn." }, "readSecurityKey": { - "message": "Read security key" + "message": "Baca kunci keamanan" }, "awaitingSecurityKeyInteraction": { - "message": "Awaiting security key interaction..." + "message": "Menunggu interaksi kunci keamanan..." }, "loginUnavailable": { "message": "Info Masuk Tidak Tersedia" @@ -1474,7 +1474,7 @@ "message": "Opsi Info Masuk Dua Langkah" }, "selectTwoStepLoginMethod": { - "message": "Select two-step login method" + "message": "Pilih metode log masuk dua langkah" }, "recoveryCodeDesc": { "message": "Kehilangan akses ke semua penyedia dua faktor Anda? Gunakan kode pemulihan untuk menonaktifkan semua penyedia dua faktor dari akun Anda." @@ -1668,7 +1668,7 @@ "message": "Seret untuk mengurutkan" }, "dragToReorder": { - "message": "Drag to reorder" + "message": "Seret untuk mengubah urutan" }, "cfTypeText": { "message": "Teks" @@ -2144,7 +2144,7 @@ "message": "Pembuat nama pengguna" }, "useThisEmail": { - "message": "Use this email" + "message": "Pakai surel ini" }, "useThisPassword": { "message": "Gunakan kata sandi ini" @@ -2164,7 +2164,7 @@ "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" }, "vaultCustomization": { - "message": "Vault customization" + "message": "Penyesuaian brankas" }, "vaultTimeoutAction": { "message": "Tindakan Batas Waktu Brankas" @@ -2173,13 +2173,13 @@ "message": "Batas waktu tindakan" }, "newCustomizationOptionsCalloutTitle": { - "message": "New customization options" + "message": "Opsi penyesuaian baru" }, "newCustomizationOptionsCalloutContent": { - "message": "Customize your vault experience with quick copy actions, compact mode, and more!" + "message": "Sesuaikan pengalaman brankas Anda dengan aksi salin cepat, mode kompak, dan lainnya!" }, "newCustomizationOptionsCalloutLink": { - "message": "View all Appearance settings" + "message": "Lihat semua pengaturan Penampilan" }, "lock": { "message": "Kunci", @@ -2437,10 +2437,10 @@ "description": "A category title describing the concept of web domains" }, "blockedDomains": { - "message": "Blocked domains" + "message": "Domain terblokir" }, "learnMoreAboutBlockedDomains": { - "message": "Learn more about blocked domains" + "message": "Pelajari lebih lanjut tentang domain yang diblokir" }, "excludedDomains": { "message": "Domain yang Dikecualikan" @@ -2452,19 +2452,19 @@ "message": "Bitwarden tidak akan meminta untuk menyimpan rincian login untuk domain tersebut. Anda harus menyegarkan halaman agar perubahan diterapkan." }, "blockedDomainsDesc": { - "message": "Autofill and other related features will not be offered for these websites. You must refresh the page for changes to take effect." + "message": "Isi otomatis dan fitur terkait lain tidak akan ditawarkan bagi situs-situs web ini. Anda mesti menyegarkan halaman agar perubahan berdampak." }, "autofillBlockedNoticeV2": { - "message": "Autofill is blocked for this website." + "message": "Isi otomatis diblokir bagi situs web ini." }, "autofillBlockedNoticeGuidance": { - "message": "Change this in settings" + "message": "Ubah ini di pengaturan" }, "change": { "message": "Ubah" }, "changeButtonTitle": { - "message": "Change password - $ITEMNAME$", + "message": "Ubah kata sandi - $ITEMNAME$", "placeholders": { "itemname": { "content": "$1", @@ -2473,10 +2473,10 @@ } }, "atRiskPasswords": { - "message": "At-risk passwords" + "message": "Kata sandi yang berrisiko" }, "atRiskPasswordDescSingleOrg": { - "message": "$ORGANIZATION$ is requesting you change one password because it is at-risk.", + "message": "$ORGANIZATION$ meminta Ada mengubah satu kata sandi karena itu berrisiko.", "placeholders": { "organization": { "content": "$1", @@ -2485,7 +2485,7 @@ } }, "atRiskPasswordsDescSingleOrgPlural": { - "message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at-risk.", + "message": "$ORGANIZATION$ meminta Anda mengubah $COUNT$ kata sandi karena mereka berrisiko.", "placeholders": { "organization": { "content": "$1", @@ -2498,7 +2498,7 @@ } }, "atRiskPasswordsDescMultiOrgPlural": { - "message": "Your organizations are requesting you change the $COUNT$ passwords because they are at-risk.", + "message": "Organisasi Anda meminta Anda mengubah $COUNT$ kata sandi karena mereka berrisiko.", "placeholders": { "count": { "content": "$1", @@ -2507,10 +2507,10 @@ } }, "reviewAndChangeAtRiskPassword": { - "message": "Review and change one at-risk password" + "message": "Tinjau dan ubah satu kata sandi berrisiko" }, "reviewAndChangeAtRiskPasswordsPlural": { - "message": "Review and change $COUNT$ at-risk passwords", + "message": "Tinjau dan ubah $COUNT$ kata sandi berrisiko", "placeholders": { "count": { "content": "$1", @@ -2519,7 +2519,7 @@ } }, "changeAtRiskPasswordsFaster": { - "message": "Change at-risk passwords faster" + "message": "Ubah lebih cepat kata sandi yang berrisiko" }, "changeAtRiskPasswordsFasterDesc": { "message": "Update your settings so you can quickly autofill your passwords and generate new ones" @@ -2528,14 +2528,14 @@ "message": "Review at-risk logins" }, "reviewAtRiskPasswords": { - "message": "Review at-risk passwords" + "message": "Tinjau kata sandi yang berrisiko" }, "reviewAtRiskLoginsSlideDesc": { - "message": "Your organization passwords are at-risk because they are weak, reused, and/or exposed.", + "message": "Kata sandi organisasi Anda berrisiko karena mereka lemah, dipakai ulang, dan/atau terpapar.", "description": "Description of the review at-risk login slide on the at-risk password page carousel" }, "reviewAtRiskLoginSlideImgAlt": { - "message": "Illustration of a list of logins that are at-risk" + "message": "Ilustrasi dari daftar log masuk yang berrisiko" }, "generatePasswordSlideDesc": { "message": "Quickly generate a strong, unique password with the Bitwarden autofill menu on the at-risk site.", @@ -2561,7 +2561,7 @@ "message": "Turned on autofill" }, "dismiss": { - "message": "Dismiss" + "message": "Tutup" }, "websiteItemLabel": { "message": "Situs web $number$ (URI)", @@ -2582,7 +2582,7 @@ } }, "blockedDomainsSavedSuccess": { - "message": "Blocked domain changes saved" + "message": "Perubahan domain yang diblokir disimpan" }, "excludedDomainsSavedSuccess": { "message": "Perubahan domain yang diabaikan telah disimpan" @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Mengekspor brankas organisasi" }, @@ -3023,17 +3032,17 @@ "message": "Galat" }, "decryptionError": { - "message": "Decryption error" + "message": "Kesalahan dekripsi" }, "couldNotDecryptVaultItemsBelow": { - "message": "Bitwarden could not decrypt the vault item(s) listed below." + "message": "Bitwarden tidak bisa mendekripsi butir brankas yang tercantum di bawah." }, "contactCSToAvoidDataLossPart1": { "message": "Contact customer success", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "to avoid additional data loss.", + "message": "untuk menghindari lanjutan hilang data.", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "generateUsername": { @@ -3168,7 +3177,7 @@ } }, "forwaderInvalidOperation": { - "message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.", + "message": "$SERVICENAME$ menolak permintaan Anda. Harap hubungi penyedia layanan Anda untuk bantuan.", "description": "Displayed when the user is forbidden from using the API by the forwarding service.", "placeholders": { "servicename": { @@ -3178,7 +3187,7 @@ } }, "forwaderInvalidOperationWithMessage": { - "message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$", + "message": "$SERVICENAME$ menolak permintaan Anda: $ERRORMESSAGE$", "description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -3327,13 +3336,13 @@ "message": "Sebuah pemberitahuan dikirim ke perangkat Anda." }, "notificationSentDevicePart1": { - "message": "Unlock Bitwarden on your device or on the" + "message": "Buka kunci Bitwarden pada perangkat Anda atau pada" }, "notificationSentDeviceAnchor": { - "message": "web app" + "message": "aplikasi web" }, "notificationSentDevicePart2": { - "message": "Make sure the Fingerprint phrase matches the one below before approving." + "message": "Pastikan frasa Sidik Jari cocok dengan yang di bawah sebelum menyetujui." }, "aNotificationWasSentToYourDevice": { "message": "Sebuah pemberitahuan telah dikirim ke perangkat Anda" @@ -3348,7 +3357,7 @@ "message": "Memulai login" }, "logInRequestSent": { - "message": "Request sent" + "message": "Permintaan terkirim" }, "exposedMasterPassword": { "message": "Kata Sandi Utama yang Terpapar" @@ -4080,7 +4089,7 @@ "message": "Akun aktif" }, "bitwardenAccount": { - "message": "Bitwarden account" + "message": "Akun Bitwarden" }, "availableAccounts": { "message": "Akun yang tersedia" @@ -4203,10 +4212,10 @@ "message": "Kunci sandi dihapus" }, "autofillSuggestions": { - "message": "Autofill suggestions" + "message": "Saran isi otomatis" }, "itemSuggestions": { - "message": "Suggested items" + "message": "Butir yang disarankan" }, "autofillSuggestionsTip": { "message": "Simpan benda login untuk situs ini ke isi otomatis" @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Isi otomatis - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,8 +4303,22 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { - "message": "Copy $FIELD$, $VALUE$", + "message": "Salin $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", "placeholders": { "field": { @@ -4769,7 +4806,7 @@ } }, "itemLocation": { - "message": "Item Location" + "message": "Lokasi Item" }, "fileSend": { "message": "File Send" @@ -4835,7 +4872,7 @@ "message": "File saved to device. Manage from your device downloads." }, "showCharacterCount": { - "message": "Show character count" + "message": "Tunjukkan cacah karakter" }, "hideCharacterCount": { "message": "Hide character count" @@ -4896,15 +4933,15 @@ "description": "Heading for the password generator within the inline menu" }, "passwordRegenerated": { - "message": "Password regenerated", + "message": "Kata sandi dibuat ulang", "description": "Notification message for when a password has been regenerated" }, "saveLoginToBitwarden": { - "message": "Save login to Bitwarden?", + "message": "Simpan log masuk ke Bitwarden?", "description": "Confirmation message for saving a login to Bitwarden" }, "spaceCharacterDescriptor": { - "message": "Space", + "message": "Spasi", "description": "Represents the space key in screen reader content as a readable word" }, "tildeCharacterDescriptor": { @@ -5051,22 +5088,22 @@ "message": "Beta" }, "importantNotice": { - "message": "Important notice" + "message": "Pemberitahuan penting" }, "setupTwoStepLogin": { - "message": "Set up two-step login" + "message": "Siapkan log masuk dua langkah" }, "newDeviceVerificationNoticeContentPage1": { - "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." + "message": "Bitwarden akan mengirim suatu kode ke akun surel Anda untuk memverifikasi log masuk dari perangkat baru sejak Februari 2025." }, "newDeviceVerificationNoticeContentPage2": { - "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." + "message": "Anda dapat menyiapkan log masuk dua langkah sebagai cara alternatif untuk melindungi akun Anda atau mengubah surel Anda ke yang bisa Anda akses." }, "remindMeLater": { - "message": "Remind me later" + "message": "Ingatkan saya nanti" }, "newDeviceVerificationNoticePageOneFormContent": { - "message": "Do you have reliable access to your email, $EMAIL$?", + "message": "Apakah Anda punya akses yang handal ke surel Anda, $EMAIL$?", "placeholders": { "email": { "content": "$1", @@ -5081,7 +5118,7 @@ "message": "Ya, saya dapat mengakses surel saya secara handla" }, "turnOnTwoStepLogin": { - "message": "Turn on two-step login" + "message": "Nyalakan log masuk dua langkah" }, "changeAcctEmail": { "message": "Ubah surel akun" diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index cb8e414ca37..b18acbc79b8 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Esportando cassaforte dell'organizzazione" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Riempi automaticamente - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copia $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index b67d0b62a6e..a6dd0f709a2 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "$EMAIL$ に関連付けられた個人用保管庫のアイテムのみが、添付ファイルを含めてエクスポートされます。組織用保管庫のアイテムは含まれません。", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "組織保管庫のエクスポート" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "自動入力 - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "$FIELD$ 「$VALUE$」 をコピー", "description": "Title for a button that copies a field value to the clipboard.", @@ -5066,7 +5103,7 @@ "message": "後で再通知" }, "newDeviceVerificationNoticePageOneFormContent": { - "message": "新しいメールアドレス $EMAIL$ はあなたが管理しているものですか?", + "message": "メールアドレス $EMAIL$ は、確実にアクセスできるものですか?", "placeholders": { "email": { "content": "$1", diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 818d7cdcd19..0e11594faac 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index c9c29611deb..0c087ef7de9 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index c8aff3a6488..31ea7daa668 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index cd54ac47506..92654f84e31 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "조직 보관함을 내보내는 중" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "자동 완성 - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 8bf8a8f7518..e1536abdf83 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 7b5f077f69d..789666ef2c1 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Tiks izdoti tikai atsevišķi glabātavas vienumi, tajā skaitā pielikumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Izgūst apvienības glabātavu" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Apskatīt vienumu - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Automātiski aizpildīt - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Automātiskā aizpilde - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Ievietot starpliktuvē $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 24a096db0ef..11af6b54202 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 9a49998d3d9..472a8378bb7 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index c9c29611deb..0c087ef7de9 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 0ff4ed9486a..9a52eeea4cb 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autoutfyll - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index c9c29611deb..0c087ef7de9 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 1c2b09ca3e3..80877839adb 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Exporteert alleen de persoonlijke kluis-items, inclusief attachments, gerelateerd aan $EMAIL$. Geen kluis-items van de organisatie", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Organisatiekluis exporteren" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Item weergeven - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Automatisch invullen - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Automatisch aanvullen - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "$FIELD$, $VALUE$ kopiëren", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index c9c29611deb..0c087ef7de9 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index c9c29611deb..0c087ef7de9 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index f4c1303bd18..c345972bd4a 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Tylko poszczególne elementy sejfu łącznie z załącznikami powiązanymi z $EMAIL$ zostaną wyeksportowane. Elementy sejfu organizacji nie będą dołączone", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Eksportowanie sejfu organizacji" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Zobacz element - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autouzupełnij - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autouzupełnij - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopiuj $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 0b5d569f443..5aacad23a93 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportando cofre da organização" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Auto-preenchimento - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copiar $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index c1f1f51609c..0e467b65183 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Apenas os itens individuais do cofre, incluindo os anexos associados a $EMAIL$, serão exportados. Os itens do cofre da organização não serão incluídos", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "A exportar o cofre da organização" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Ver item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Preencher automaticamente - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Preencher automaticamente - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copiar $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 5ce73eb2e49..fa4d754e99d 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 11d8de4acbe..c775bee3289 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Будут экспортированы только отдельные элементы хранилища, включая вложения, связанные с $EMAIL$. Элементы хранилища организации включены не будут", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Экспорт хранилища организации" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Автозаполнение - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Скопировать $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 721d16a2eee..12bac21e11f 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 897db9f3176..cd4095f8ff5 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Exportované budú iba položky osobného trezora spojené s $EMAIL$. Položky trezora organizácie nebudú zahrnuté v exporte", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exportovanie trezora organizácie" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Zobraziť položku - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Automatické vyplnenie - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Automatické vyplnenie - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopírovať $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index f6a543ea5ea..c77d4cb55f4 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 3c7bef94c13..5e325c6a97c 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Извоз сефа организације" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Ауто-пуњење - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Копирај $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 6db7a22490e..e3ff6986cae 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index c9c29611deb..0c087ef7de9 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index c545f802d64..e2225327664 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Autofill - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index e69b33d63af..e8788a00d70 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Yalnızca $EMAIL$ ile ilişkili kişisel kasadaki kayıtlar ve dosyalar dışa aktarılacaktır. Kuruluş kasasındaki kayıtlar dahil edilmeyecektir", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Kuruluş kasasını dışa aktarma" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "Kaydı görüntüle - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Otomatik doldur - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Otomatik doldur - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Kopyala: $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index d18d90babff..6bd9ca7fc36 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Експортування сховища організації" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Автозаповнення – $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Копіювати $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index db0da3b5874..35c553f72f1 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "Đang xuất dữ liệu kho tổ chức" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "Tự động điền - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index bdfaeb92531..fc6f70cb6a4 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "仅会导出与 $EMAIL$ 关联的个人密码库项目(包括附件)。不包括组织密码库项目。", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "正在导出组织密码库" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "查看项目 - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "自动填充 - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "自动填充 - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "复制 $FIELD$,$VALUE$", "description": "Title for a button that copies a field value to the clipboard.", diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 37e450c8fd2..365aae61ebf 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -3007,6 +3007,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultTitle": { "message": "正在匯出組織密碼庫" }, @@ -4270,6 +4279,20 @@ } } }, + "viewItemTitleWithField": { + "message": "View item - $ITEMNAME$ - $FIELD$", + "description": "Title for a link that opens a view for an item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "autofillTitle": { "message": "自動填入 - $ITEMNAME$", "description": "Title for a button that autofills a login item.", @@ -4280,6 +4303,20 @@ } } }, + "autofillTitleWithField": { + "message": "Autofill - $ITEMNAME$ - $FIELD$", + "description": "Title for a button that autofills a login item.", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "field": { + "content": "$2", + "example": "Username" + } + } + }, "copyFieldValue": { "message": "Copy $FIELD$, $VALUE$", "description": "Title for a button that copies a field value to the clipboard.", From 8b14b0c09f0df761ba2ea28dd2c35a4f8d2e0f96 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Fri, 28 Mar 2025 12:34:52 +0100 Subject: [PATCH 038/228] Autosync the updated translations (#14039) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/af/messages.json | 9 ++ apps/web/src/locales/ar/messages.json | 9 ++ apps/web/src/locales/az/messages.json | 9 ++ apps/web/src/locales/be/messages.json | 9 ++ apps/web/src/locales/bg/messages.json | 9 ++ apps/web/src/locales/bn/messages.json | 9 ++ apps/web/src/locales/bs/messages.json | 9 ++ apps/web/src/locales/ca/messages.json | 9 ++ apps/web/src/locales/cs/messages.json | 9 ++ apps/web/src/locales/cy/messages.json | 9 ++ apps/web/src/locales/da/messages.json | 9 ++ apps/web/src/locales/de/messages.json | 9 ++ apps/web/src/locales/el/messages.json | 9 ++ apps/web/src/locales/en_GB/messages.json | 9 ++ apps/web/src/locales/en_IN/messages.json | 9 ++ apps/web/src/locales/eo/messages.json | 9 ++ apps/web/src/locales/es/messages.json | 9 ++ apps/web/src/locales/et/messages.json | 9 ++ apps/web/src/locales/eu/messages.json | 9 ++ apps/web/src/locales/fa/messages.json | 9 ++ apps/web/src/locales/fi/messages.json | 9 ++ apps/web/src/locales/fil/messages.json | 9 ++ apps/web/src/locales/fr/messages.json | 9 ++ apps/web/src/locales/gl/messages.json | 9 ++ apps/web/src/locales/he/messages.json | 9 ++ apps/web/src/locales/hi/messages.json | 9 ++ apps/web/src/locales/hr/messages.json | 9 ++ apps/web/src/locales/hu/messages.json | 9 ++ apps/web/src/locales/id/messages.json | 9 ++ apps/web/src/locales/it/messages.json | 9 ++ apps/web/src/locales/ja/messages.json | 123 ++++++++++++----------- apps/web/src/locales/ka/messages.json | 9 ++ apps/web/src/locales/km/messages.json | 9 ++ apps/web/src/locales/kn/messages.json | 9 ++ apps/web/src/locales/ko/messages.json | 9 ++ apps/web/src/locales/lv/messages.json | 9 ++ apps/web/src/locales/ml/messages.json | 9 ++ apps/web/src/locales/mr/messages.json | 9 ++ apps/web/src/locales/my/messages.json | 9 ++ apps/web/src/locales/nb/messages.json | 9 ++ apps/web/src/locales/ne/messages.json | 9 ++ apps/web/src/locales/nl/messages.json | 9 ++ apps/web/src/locales/nn/messages.json | 9 ++ apps/web/src/locales/or/messages.json | 9 ++ apps/web/src/locales/pl/messages.json | 9 ++ apps/web/src/locales/pt_BR/messages.json | 9 ++ apps/web/src/locales/pt_PT/messages.json | 9 ++ apps/web/src/locales/ro/messages.json | 9 ++ apps/web/src/locales/ru/messages.json | 9 ++ apps/web/src/locales/si/messages.json | 9 ++ apps/web/src/locales/sk/messages.json | 9 ++ apps/web/src/locales/sl/messages.json | 9 ++ apps/web/src/locales/sr/messages.json | 9 ++ apps/web/src/locales/sr_CS/messages.json | 9 ++ apps/web/src/locales/sv/messages.json | 9 ++ apps/web/src/locales/te/messages.json | 9 ++ apps/web/src/locales/th/messages.json | 9 ++ apps/web/src/locales/tr/messages.json | 9 ++ apps/web/src/locales/uk/messages.json | 9 ++ apps/web/src/locales/vi/messages.json | 9 ++ apps/web/src/locales/zh_CN/messages.json | 13 ++- apps/web/src/locales/zh_TW/messages.json | 9 ++ 62 files changed, 617 insertions(+), 59 deletions(-) diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 3b9168b29b2..fa25494f4bf 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index 13777f942de..4a289082039 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index f092620b00a..dfafeab1877 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Yalnız $EMAIL$ ilə əlaqələndirilmiş qoşmalar daxil olmaqla fərdi anbar elementləri xaricə köçürüləcək. Təşkilat anbar elementləri daxil edilməyəcək", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat seyfi xaricə köçürüləcək. Fərdi seyfdə və digər təşkilatlardakı elementlər daxil edilməyəcək.", "placeholders": { diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index 2e5aeabff32..a22b463f4f2 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Толькі сховішча арганізацыі, якія звязаны з $ORGANIZATION$ будуць экспартаваны. Элементы асабістага сховішча і элементы з іншых арганізацый не будуць уключаны.", "placeholders": { diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index 04d4e25bb7c..0599d3a73f2 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Ще бъдат изнесени само записите и прикачените файлове от личния трезор свързан с $EMAIL$. Записите в трезора на организацията няма да бъдат включени.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Ще бъдат изнесени само записите от трезора свързан с $ORGANIZATION$. Записите в отделните лични трезори и тези в други организации няма да бъдат включени.", "placeholders": { diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 8c082c57e30..15ca72e1642 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 7efb47bd86c..2c0f177ad67 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index a4f2aeb7f55..d394a75b70f 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Només s'exportarà la caixa forta de l'organització associada a $ORGANIZATION$. No s'inclouran els elements de les caixes fortes individuals ni d'altres organitzacions.", "placeholders": { diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 46b510a6459..552a717ab82 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Budou exportovány jen osobní položky trezoru včetně příloh spojené s účtem $EMAIL$. Nebudou zahrnuty položky trezoru v organizaci.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Exportován bude jen trezor organizace přidružený k položce $ORGANIZATION$. Osobní položky trezoru a položky z jiných organizací nebudou zahrnuty.", "placeholders": { diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 2fd61e01526..ba8bd6bd8a4 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index f8a335011e5..dddef79ddd0 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Kun organisationsboksen tilknyttet $ORGANIZATION$ eksporteres. Emner i individuelle bokse eller andre organisationer medtages ikke.", "placeholders": { diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 135ab8af923..b5eeb9db7ce 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Nur der mit $ORGANIZATION$ verbundene Organisationstresor wird exportiert. Einträge in persönlichen Tresoren oder anderen Organisationen werden nicht berücksichtigt.", "placeholders": { diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index e20b5cc52a0..27e39f9798e 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index b211ec070ab..b4f0ace607a 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organisation vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organisations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 6b4b6e11ab3..a8c4bfca62e 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organisation vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organisation vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organisations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index ce40767f4c1..c31274e6198 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 1c71a29f48b..80b982bd928 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index f9a5b810ded..a55fce63349 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index d0e99c73e66..a8b64594065 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index 04cddc65f2f..4657d4a9732 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "فقط گاوصدوق سازمان مرتبط با $ORGANIZATION$ برون ریزی خواهد شد. موارد موجود در گاوصندوق‌های فردی یا سایر سازمان‌ها شامل نمی‌شوند.", "placeholders": { diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index 9427a4e8e22..38671c12f79 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Vain organisaatioon $ORGANIZATION$ liitetyn holvin kohteet viedään. Yksityisen holvin ja muiden organisaatioiden kohteita ei sisällytetä.", "placeholders": { diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index 414bbda91d8..0a07cf17526 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index 95aa573f14b..e26b5ef1250 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Seuls les éléments individuels du coffre et les pièces jointes associées à $EMAIL$ seront exportés. Les éléments du coffre de l'organisation ne seront pas inclus", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Seul le coffre d'organisation associé à $ORGANIZATION$ sera exporté. Les éléments dans les coffres individuels ou d'autres organisations ne seront pas inclus.", "placeholders": { diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 38a94f9307f..6964a867768 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 4df39c8496f..115482c021b 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "רק כספת הארגון המשויכת עם $ORGANIZATION$ תיוצא. פריטים בכספת אישית או ארגונים אחרים לא יכללו.", "placeholders": { diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index 1cab0dc843b..bdd11c6561f 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index 65c42a0cbf0..5ae2ddcfc0b 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Izvest će se samo stavke i privici osobnog trezora povezanog s $EMAIL$. Stavke organizacijskog trezora neće biti uključene", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Izvest će se samo organizacijski trezor povezan s $ORGANIZATION$. Stavke iz osobnih trezora i stavke iz drugih organizacija neće biti uključene.", "placeholders": { diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index 3d6a2e258a4..b7a51e5048b 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Csak $EMAIL$ email címmel társított személyes széf elemek kerülnek exportálásra. Ebbe nem kerülnek be a szervezeti széf elemek.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Csak$ORGANIZATION$ névvel társított szervezeti széf elemek kerülnek exportálásra. Ebbe nem kerülnek be a személyes és más szervezeti széf elemek.", "placeholders": { diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 22b4e3a6132..dc3ba77328f 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index 0cbc425d588..853c580c82d 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Solo la cassaforte dell'organizzazione associata a $ORGANIZATION$ sarà esportata. Elementi nelle casseforti individuali o in altre organizzazioni non saranno inclusi.", "placeholders": { diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 6860b30b360..d445184774c 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -474,7 +474,7 @@ "message": "フォルダーを編集" }, "editWithName": { - "message": "Edit $ITEM$: $NAME$", + "message": "$ITEM$: $NAME$ を編集", "placeholders": { "item": { "content": "$1", @@ -1202,7 +1202,7 @@ "message": "デバイスとは何ですか?" }, "aDeviceIs": { - "message": "A device is a unique installation of the Bitwarden app where you have logged in. Reinstalling, clearing app data, or clearing your cookies could result in a device appearing multiple times." + "message": "デバイスとは、あなたがログインしている Bitwarden アプリの一意なインストールです。 再インストールや、アプリデータの消去、 Cookie の消去をすると、同じデバイスが複数回表示される場合があります。" }, "logInInitiated": { "message": "ログイン開始" @@ -1894,22 +1894,22 @@ "message": "新しいデバイスからのログイン" }, "turnOffNewDeviceLoginProtection": { - "message": "Turn off new device login protection" + "message": "新しいデバイスからのログイン保護をオフにする" }, "turnOnNewDeviceLoginProtection": { - "message": "Turn on new device login protection" + "message": "新しいデバイスからのログイン保護をオンにする" }, "turnOffNewDeviceLoginProtectionModalDesc": { - "message": "Proceed below to turn off the verification emails bitwarden sends when you login from a new device." + "message": "続行すると、新しいデバイスからログインしたときに Bitwarden が送信する認証メールをオフにします。" }, "turnOnNewDeviceLoginProtectionModalDesc": { - "message": "Proceed below to have bitwarden send you verification emails when you login from a new device." + "message": "続行すると、新しいデバイスからログインする際に Bitwarden から認証メールを送信します。" }, "turnOffNewDeviceLoginProtectionWarning": { - "message": "With new device login protection turned off, anyone with your master password can access your account from any device. To protect your account without verification emails, set up two-step login." + "message": "新しいデバイスからのログイン保護をオフにすると、マスターパスワードを持つ人は誰でも、どのデバイスからでもあなたのアカウントにアクセスできます。認証メールなしでアカウントを保護するには、2段階認証によるログインを設定してください。" }, "accountNewDeviceLoginProtectionSaved": { - "message": "New device login protection changes saved" + "message": "新しいデバイスからのログイン保護の変更が保存されました" }, "sessionsDeauthorized": { "message": "全てのセッションを無効化" @@ -2175,7 +2175,7 @@ "message": "2段階認証を有効にすると Bitwarden アカウントから永久に閉め出されてしまうことがあります。リカバリーコードがあれば、通常の2段階認証プロバイダを使えなくなったとき (デバイスの紛失等) でもアカウントにアクセスできます。アカウントにアクセスできなくなっても Bitwarden はサポート出来ないため、リカバリーコードを書き出すか印刷し安全な場所で保管しておくことを推奨します。" }, "yourSingleUseRecoveryCode": { - "message": "Your single-use recovery code can be used to turn off two-step login in the event that you lose access to your two-step login provider. Bitwarden recommends you write down the recovery code and keep it in a safe place." + "message": "2段階認証プロバイダーへのアクセスを失った場合は、使い捨てのリカバリーコードを使用して2段階認証をオフにできます。 Bitwarden では、リカバリーコードを書き留めて安全な場所に保管することをお勧めしています。" }, "viewRecoveryCode": { "message": "リカバリーコードを確認" @@ -2216,7 +2216,7 @@ "message": "管理" }, "manageCollection": { - "message": "Manage collection" + "message": "コレクションの管理" }, "viewItems": { "message": "アイテムを表示" @@ -2429,7 +2429,7 @@ "message": "セキュリティキーの読み取り中に問題が発生しました。もう一度やり直して下さい。" }, "twoFactorWebAuthnWarning1": { - "message": "Due to platform limitations, WebAuthn cannot be used on all Bitwarden applications. You should set up another two-step login provider so that you can access your account when WebAuthn cannot be used." + "message": "プラットフォームの制限により、 WebAuthn は全ての Bitwarden アプリケーションで使用できるわけではありません。 WebAuthn が使用できない場合に備えて、他の2段階認証プロバイダーを設定しておくことをおすすめします。" }, "twoFactorRecoveryYourCode": { "message": "二段階認証のリカバリーコード" @@ -3396,10 +3396,10 @@ } }, "inviteSingleEmailDesc": { - "message": "You have 1 invite remaining." + "message": "招待は残り 1 個です。" }, "inviteZeroEmailDesc": { - "message": "You have 0 invites remaining." + "message": "招待は残り 0 個です。" }, "userUsingTwoStep": { "message": "このユーザーはアカウントを保護するため二段階認証を利用しています。" @@ -3853,7 +3853,7 @@ } }, "unlinkedSso": { - "message": "Unlinked SSO." + "message": "SSO のリンクを解除しました。" }, "unlinkedSsoUser": { "message": "ユーザー $ID$ にリンクされていない SSO", @@ -3904,22 +3904,22 @@ "message": "デバイス" }, "loginStatus": { - "message": "Login status" + "message": "ログイン状態" }, "firstLogin": { - "message": "First login" + "message": "初回ログイン" }, "trusted": { - "message": "Trusted" + "message": "信頼済み" }, "needsApproval": { - "message": "Needs approval" + "message": "承認が必要" }, "areYouTryingtoLogin": { - "message": "Are you trying to log in?" + "message": "ログインしようとしていますか?" }, "logInAttemptBy": { - "message": "Login attempt by $EMAIL$", + "message": "$EMAIL$ によるログインの試行", "placeholders": { "email": { "content": "$1", @@ -3928,7 +3928,7 @@ } }, "deviceType": { - "message": "Device Type" + "message": "デバイスタイプ" }, "ipAddress": { "message": "IP アドレス" @@ -3965,7 +3965,7 @@ "message": "たった今" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "$MINUTES$ 分前に要求されました", "placeholders": { "minutes": { "content": "$1", @@ -4097,10 +4097,10 @@ "message": "サポートされていないブラウザを使用しています。ウェブ保管庫が正しく動作しないかもしれません。" }, "youHaveAPendingLoginRequest": { - "message": "You have a pending login request from another device." + "message": "別のデバイスからの保留中のログインリクエストがあります。" }, "reviewLoginRequest": { - "message": "Review login request" + "message": "ログインリクエストの内容を確認" }, "freeTrialEndPromptCount": { "message": "無料体験はあと $COUNT$ 日で終了します。", @@ -4197,7 +4197,7 @@ "message": "もし通常の二段階認証の方法でアカウントにアクセスできなくなった場合、リカバリーコードにより全ての二段階認証プロバイダを無効化することができます。" }, "logInBelowUsingYourSingleUseRecoveryCode": { - "message": "Log in below using your single-use recovery code. This will turn off all two-step providers on your account." + "message": "使い捨てのリカバリーコードを使用して以下からログインしてください。これにより、アカウントのすべての2段階認証プロバイダーが無効になります。" }, "recoverAccountTwoStep": { "message": "二段階認証ログインの回復" @@ -4509,7 +4509,7 @@ } }, "reorderFieldUp": { - "message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$", + "message": "$LABEL$ を上に移動しました。$INDEX$ / $LENGTH$", "placeholders": { "label": { "content": "$1", @@ -4526,7 +4526,7 @@ } }, "reorderFieldDown": { - "message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$", + "message": "$LABEL$ を下に移動しました。$INDEX$ / $LENGTH$", "placeholders": { "label": { "content": "$1", @@ -4814,7 +4814,7 @@ "message": "マスターパスワードの強度に最低要件を設定する。" }, "passwordStrengthScore": { - "message": "Password strength score $SCORE$", + "message": "パスワードの強度スコア $SCORE$", "placeholders": { "score": { "content": "$1", @@ -5103,14 +5103,14 @@ "message": "組織の所有者および管理者は、このポリシーの執行から除外されます。" }, "limitSendViews": { - "message": "Limit views" + "message": "表示の制限" }, "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", + "message": "上限に達すると、誰もこの Send を表示できなくなります。", "description": "Displayed under the limit views field on Send" }, "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", + "message": "残り $ACCESSCOUNT$ 回", "description": "Displayed under the limit views field on Send", "placeholders": { "accessCount": { @@ -5120,11 +5120,11 @@ } }, "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." }, "sendTypeTextToShare": { - "message": "Text to share" + "message": "共有するテキスト" }, "sendTypeFile": { "message": "ファイル" @@ -5133,7 +5133,7 @@ "message": "テキスト" }, "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." }, "createSend": { @@ -5161,14 +5161,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "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." }, "deletionDate": { "message": "削除日時" }, "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." }, "expirationDate": { @@ -5218,7 +5218,7 @@ "message": "保留中の削除" }, "hideTextByDefault": { - "message": "Hide text by default" + "message": "デフォルトでテキストを隠す" }, "expired": { "message": "期限切れ" @@ -5689,7 +5689,7 @@ "message": "削除と有効期限の保存中にエラーが発生しました。" }, "hideYourEmail": { - "message": "Hide your email address from viewers." + "message": "閲覧者にメールアドレスを見せないようにします。" }, "webAuthnFallbackMsg": { "message": "二段階認証を確認するには、下のボタンをクリックしてください。" @@ -5698,10 +5698,10 @@ "message": "WebAuthn の認証" }, "readSecurityKey": { - "message": "Read security key" + "message": "セキュリティキーの読み取り" }, "awaitingSecurityKeyInteraction": { - "message": "Awaiting security key interaction..." + "message": "セキュリティキーとの通信を待ち受け中…" }, "webAuthnNotSupported": { "message": "WebAuthn はこのブラウザではサポートされていません。" @@ -5917,17 +5917,17 @@ "message": "エラー" }, "decryptionError": { - "message": "Decryption error" + "message": "復号エラー" }, "couldNotDecryptVaultItemsBelow": { - "message": "Bitwarden could not decrypt the vault item(s) listed below." + "message": "Bitwarden は以下の保管庫のアイテムを復号できませんでした。" }, "contactCSToAvoidDataLossPart1": { - "message": "Contact customer success", + "message": "カスタマーサクセスに問い合わせて、", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "contactCSToAvoidDataLossPart2": { - "message": "to avoid additional data loss.", + "message": "さらなるデータ損失を回避してください。", "description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'" }, "accountRecoveryManageUsers": { @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "$EMAIL$ に関連付けられた個人用保管庫のアイテムのみが、添付ファイルを含めてエクスポートされます。組織用保管庫のアイテムは含まれません。", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "$ORGANIZATION$ に関連付けられた組織保管庫のみがエクスポートされます。個々の保管庫または他の組織にあるアイテムは含まれません。", "placeholders": { @@ -6845,7 +6854,7 @@ "message": "ドメインに設定されたキャッチオール受信トレイを使用します。" }, "useThisEmail": { - "message": "Use this email" + "message": "このメールアドレスを使う" }, "random": { "message": "ランダム", @@ -6997,7 +7006,7 @@ } }, "forwaderInvalidOperation": { - "message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.", + "message": "$SERVICENAME$ はリクエストを拒否しました。サービスプロバイダーにお問い合わせください。", "description": "Displayed when the user is forbidden from using the API by the forwarding service.", "placeholders": { "servicename": { @@ -7007,7 +7016,7 @@ } }, "forwaderInvalidOperationWithMessage": { - "message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$", + "message": "$SERVICENAME$ があなたのリクエストを拒否しました: $ERRORMESSAGE$", "description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -10159,7 +10168,7 @@ } }, "userLeftOrganization": { - "message": "User $ID$ left organization", + "message": "ユーザー $ID$ が組織から脱退しました", "placeholders": { "id": { "content": "$1", @@ -10168,7 +10177,7 @@ } }, "suspendedOrganizationTitle": { - "message": "The $ORGANIZATION$ is suspended", + "message": "$ORGANIZATION$ は一時停止されています", "placeholders": { "organization": { "content": "$1", @@ -10177,25 +10186,25 @@ } }, "suspendedUserOrgMessage": { - "message": "Contact your organization owner for assistance." + "message": "組織の所有者にお問い合わせください。" }, "suspendedOwnerOrgMessage": { - "message": "To regain access to your organization, add a payment method." + "message": "組織へのアクセスを取り戻すには、支払い方法を追加してください。" }, "deleteMembers": { - "message": "Delete members" + "message": "メンバーを削除" }, "noSelectedMembersApplicable": { - "message": "This action is not applicable to any of the selected members." + "message": "この操作は、選択されたメンバーには適用できません。" }, "deletedSuccessfully": { - "message": "Deleted successfully" + "message": "正常に削除されました" }, "freeFamiliesSponsorship": { - "message": "Remove Free Bitwarden Families sponsorship" + "message": "Bitwarden ファミリープランの無償利用へのスポンサーを解除" }, "freeFamiliesSponsorshipPolicyDesc": { - "message": "Do not allow members to redeem a Families plan through this organization." + "message": "メンバーがこの組織を通じてファミリープランを引き換えられないようにします。" }, "verifyBankAccountWithStatementDescriptorWarning": { "message": "Payment with a bank account is only available to customers in the United States. You will be required to verify your bank account. We will make a micro-deposit within the next 1-2 business days. Enter the statement descriptor code from this deposit on the organization's billing page to verify the bank account. Failure to verify the bank account will result in a missed payment and your subscription being suspended." @@ -10545,6 +10554,6 @@ "message": "These events are examples only and do not reflect real events within your Bitwarden organization." }, "cannotCreateCollection": { - "message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections." + "message": "無料版の組織ではコレクションは 2 つまでです。さらにコレクションを追加するには有料プランにアップグレードしてください。" } } diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index f5f4bfb3ae1..5ace08617ff 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index f334131b69b..59eac9b372b 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index dbf2cab3bb5..8132c9a2db5 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 1f3bbda3736..6e59f0ccf88 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "$ORGANIZATION$ 조직과 연관된 조직 보관함만 내보내기됩니다. 개인 보관함이나 다른 조직의 항목은 포함되지 않습니다.", "placeholders": { diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index 7c8c3b2831e..e59f6078bf5 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Tiks izdoti tikai atsevišķi glabātavas vienumi, tajā skaitā pielikumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Atsevišķu glabātavu vai citu apvienību vienumi netiks iekļauti.", "placeholders": { diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index 46ce3b62533..10ae27a2139 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index f334131b69b..59eac9b372b 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index f334131b69b..59eac9b372b 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 24285b8cb9f..bc36ba82ff1 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 8183efd5f9b..c947c0bb716 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 6b752b5a195..1bc731e3ceb 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Exporteert alleen de persoonlijke kluis-items, inclusief attachments, gerelateerd aan $EMAIL$. Geen kluis-items van de organisatie", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Exporteert alleen de organisatiekluis van $ORGANIZATION$. Geen persoonlijke kluis-items of items van andere organisaties.", "placeholders": { diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index 23d4d3b3089..ae64568bd79 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index f334131b69b..59eac9b372b 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index 9ef5c95bf86..f793b249148 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Tylko poszczególne elementy sejfu łącznie z załącznikami powiązanymi z $EMAIL$ zostaną wyeksportowane. Elementy sejfu organizacji nie będą dołączone", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Tylko sejf organizacji powiązany z $ORGANIZATION$ zostanie wyeksportowany. Pozycje w poszczególnych sejfach lub innych organizacji nie będą uwzględnione.", "placeholders": { diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index ea43fd4eae6..3ac5afa3110 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Apenas o cofre da organização associado com $ORGANIZATION$ será exportado. Itens do cofre pessoal e itens de outras organizações não serão incluídos.", "placeholders": { diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 9bbda05c93f..1cd3f9c9b1b 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Apenas os itens individuais do cofre, incluindo os anexos associados a $EMAIL$, serão exportados. Os itens do cofre da organização não serão incluídos", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Apenas o cofre da organização associado a $ORGANIZATION$ será exportado. Os itens em cofres individuais ou noutras organizações não serão incluídos.", "placeholders": { diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index c57ad26d251..f67c9550655 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index a31d13d227e..2bdaeb63206 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Будут экспортированы только отдельные элементы хранилища, включая вложения, связанные с $EMAIL$. Элементы хранилища организации включены не будут", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Будет экспортировано только хранилище организации, связанное с $ORGANIZATION$. Элементы из личных хранилищ и из других организаций включены не будут.", "placeholders": { diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index fc79da1352b..bbb01d1aa49 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index fdf57456a4c..339a73bbffb 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Exportované budú iba položy osobného trezora spojené s $EMAIL$. Položky trezora organizácie nebudú zahrnuté v exporte", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Exportované budú iba položky trezora organizácie spojené s $ORGANIZATION$. Položky osobného trezora a položky z iných organizácií nebudú zahrnuté.", "placeholders": { diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 1652841b650..d14b4f13ecc 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/sr/messages.json b/apps/web/src/locales/sr/messages.json index 005fa7602b5..2ac4a713189 100644 --- a/apps/web/src/locales/sr/messages.json +++ b/apps/web/src/locales/sr/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Биће извезен само сеф организације повезан са $ORGANIZATION$. Ставке у појединачним сефовима или другим организацијама неће бити укључене.", "placeholders": { diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index d141d8301fc..d84f3192dea 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index f7d6414c36c..e4cc2cdeb67 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index f334131b69b..59eac9b372b 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index 7d2685807b3..3b05ffc9000 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 030fd0dd9b2..ef8701ce4b1 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Yalnızca $EMAIL$ ile ilişkili kişisel kasadaki kayıtlar ve dosyalar dışa aktarılacaktır. Kuruluş kasasındaki kayıtlar dahil edilmeyecektir", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 7d9eb316821..e175fe99db8 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Записи особистих сховищ або інших організацій не будуть включені.", "placeholders": { diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index f0a0215f823..f7e8f9f4037 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", "placeholders": { diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 00bf9bc5973..6bb79aa1971 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -2958,7 +2958,7 @@ "description": "Past tense status of an invoice. ex. Paid or unpaid." }, "unpaid": { - "message": "待支付", + "message": "未支付", "description": "Past tense status of an invoice. ex. Paid or unpaid." }, "transactions": { @@ -4799,7 +4799,7 @@ "message": "您必须至少选择一个集合。" }, "couldNotChargeCardPayInvoice": { - "message": "我们无法从您的支付卡中扣款。请查看并支付下面列出的待支付账单。" + "message": "我们无法从您的支付卡中扣款。请查看并支付下面列出的未支付账单。" }, "minLength": { "message": "最小长度" @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "仅会导出与 $EMAIL$ 关联的个人密码库项目(包括附件)。不包括组织密码库项目。", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库数据。不包括个人密码库和其他组织中的项目。", "placeholders": { diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index beca8f74485..c23def1464d 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -6741,6 +6741,15 @@ } } }, + "exportingIndividualVaultWithAttachmentsDescription": { + "message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "exportingOrganizationVaultDesc": { "message": "僅匯出與 $ORGANIZATION$ 相關的組織密碼庫項目。個人密碼庫項目或其他組織中的項目將不包括在内。", "placeholders": { From 7ed21453937eb18ed9af96b9b57342f8f54f5f7d Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 28 Mar 2025 16:20:36 +0100 Subject: [PATCH 039/228] Set correct filename extensions on vault-export (#14018) Co-authored-by: Daniel James Smith --- .../src/services/individual-vault-export.service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 0fc1f336b90..765de042d32 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -81,7 +81,7 @@ export class IndividualVaultExportService return { type: "text/plain", data: await this.buildPasswordExport(exportVault.data, password), - fileName: ExportHelper.getFileName("json"), + fileName: ExportHelper.getFileName("", "encrypted_json"), } as ExportedVaultAsString; } @@ -126,7 +126,7 @@ export class IndividualVaultExportService return { type: "application/zip", data: blobData, - fileName: ExportHelper.getFileName("json"), + fileName: ExportHelper.getFileName("", "json"), } as ExportedVaultAsBlob; } @@ -185,14 +185,14 @@ export class IndividualVaultExportService return { type: "text/plain", data: this.buildCsvExport(decFolders, decCiphers), - fileName: ExportHelper.getFileName("csv"), + fileName: ExportHelper.getFileName("", "csv"), } as ExportedVaultAsString; } return { type: "text/plain", data: this.buildJsonExport(decFolders, decCiphers), - fileName: ExportHelper.getFileName("json"), + fileName: ExportHelper.getFileName("", "json"), } as ExportedVaultAsString; } @@ -250,7 +250,7 @@ export class IndividualVaultExportService return { type: "text/plain", data: JSON.stringify(jsonDoc, null, " "), - fileName: ExportHelper.getFileName("json"), + fileName: ExportHelper.getFileName("", "json"), } as ExportedVaultAsString; } From 6204e2a092c50ed192532a0caac2cbbe2283ba4b Mon Sep 17 00:00:00 2001 From: Vicki League Date: Fri, 28 Mar 2025 13:41:53 -0400 Subject: [PATCH 040/228] [CL-550] Update cipher form story (#14019) --- libs/vault/src/cipher-form/cipher-form.mdx | 3 ++ .../src/cipher-form/cipher-form.stories.ts | 50 +++++++++++++++---- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/libs/vault/src/cipher-form/cipher-form.mdx b/libs/vault/src/cipher-form/cipher-form.mdx index ed2e799b9f3..658fcd38d11 100644 --- a/libs/vault/src/cipher-form/cipher-form.mdx +++ b/libs/vault/src/cipher-form/cipher-form.mdx @@ -12,6 +12,9 @@ It is configured via a `CipherFormConfig` object that is passed to the component create it. A default implementation of the `CipherFormConfigService` exists in the `@bitwarden/vault` library. +The cipher form has a slot for `attachment-button`, which should be included when the form is in +`edit` mode. + diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 5d6464b4c79..52d70e8652a 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -29,7 +29,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; -import { AsyncActionsModule, ButtonModule, ToastService } from "@bitwarden/components"; +import { AsyncActionsModule, ButtonModule, ItemModule, ToastService } from "@bitwarden/components"; import { CipherFormConfig, CipherFormGenerationService, @@ -131,7 +131,7 @@ export default { component: CipherFormComponent, decorators: [ moduleMetadata({ - imports: [CipherFormModule, AsyncActionsModule, ButtonModule], + imports: [CipherFormModule, AsyncActionsModule, ButtonModule, ItemModule], providers: [ { provide: CipherFormService, @@ -246,7 +246,7 @@ export default { type Story = StoryObj; -export const Default: Story = { +export const Add: Story = { render: (args) => { return { props: { @@ -254,15 +254,28 @@ export const Default: Story = { ...args, }, template: /*html*/ ` - - + `, }; }, }; export const Edit: Story = { - ...Default, + render: (args) => { + return { + props: { + onSave: actionsData.onSave, + ...args, + }, + template: /*html*/ ` + + + + + + `, + }; + }, args: { config: { ...defaultConfig, @@ -273,7 +286,7 @@ export const Edit: Story = { }; export const PartialEdit: Story = { - ...Default, + ...Add, args: { config: { ...defaultConfig, @@ -284,7 +297,7 @@ export const PartialEdit: Story = { }; export const Clone: Story = { - ...Default, + ...Add, args: { config: { ...defaultConfig, @@ -294,8 +307,27 @@ export const Clone: Story = { }, }; +export const WithSubmitButton: Story = { + render: (args) => { + return { + props: { + onSave: actionsData.onSave, + ...args, + }, + template: /*html*/ ` +
+ +
+
+ +
+ `, + }; + }, +}; + export const NoPersonalOwnership: Story = { - ...Default, + ...Add, args: { config: { ...defaultConfig, From d5f033efa2a50e82780dd140b0431eb7bb455def Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Fri, 28 Mar 2025 12:51:20 -0500 Subject: [PATCH 041/228] refactor(auth): [PM-9179] remove deprecated TwoFactorComponents Remove deprecated TwoFactorComponentsV1 and TwoFactorOptionsComponentV1 components, related functionality (unauthUiRefreshSwap) and orphaned styles/translation messages. --- apps/browser/src/_locales/en/messages.json | 36 -- .../two-factor-options-v1.component.html | 29 - .../popup/two-factor-options-v1.component.ts | 56 -- .../auth/popup/two-factor-v1.component.html | 196 ------- .../src/auth/popup/two-factor-v1.component.ts | 260 --------- apps/browser/src/popup/app-routing.module.ts | 53 +- apps/browser/src/popup/app.module.ts | 4 - apps/browser/src/popup/scss/misc.scss | 10 - apps/desktop/src/app/app-routing.module.ts | 39 +- apps/desktop/src/app/app.module.ts | 4 - .../auth/two-factor-options-v1.component.html | 33 -- .../auth/two-factor-options-v1.component.ts | 24 - .../src/auth/two-factor-v1.component.html | 175 ------ .../src/auth/two-factor-v1.component.ts | 190 ------- apps/desktop/src/locales/en/messages.json | 27 - apps/desktop/src/scss/pages.scss | 2 - .../auth/two-factor-options-v1.component.html | 45 -- .../auth/two-factor-options-v1.component.ts | 52 -- .../src/app/auth/two-factor-v1.component.html | 106 ---- .../src/app/auth/two-factor-v1.component.ts | 164 ------ apps/web/src/app/oss-routing.module.ts | 69 +-- .../src/app/shared/loose-components.module.ts | 6 - apps/web/src/locales/en/messages.json | 28 +- .../two-factor-options-v1.component.ts | 44 -- .../two-factor-v1.component.spec.ts | 505 ----------------- .../components/two-factor-v1.component.ts | 514 ------------------ .../functions/unauth-ui-refresh-route-swap.ts | 36 -- 27 files changed, 55 insertions(+), 2652 deletions(-) delete mode 100644 apps/browser/src/auth/popup/two-factor-options-v1.component.html delete mode 100644 apps/browser/src/auth/popup/two-factor-options-v1.component.ts delete mode 100644 apps/browser/src/auth/popup/two-factor-v1.component.html delete mode 100644 apps/browser/src/auth/popup/two-factor-v1.component.ts delete mode 100644 apps/desktop/src/auth/two-factor-options-v1.component.html delete mode 100644 apps/desktop/src/auth/two-factor-options-v1.component.ts delete mode 100644 apps/desktop/src/auth/two-factor-v1.component.html delete mode 100644 apps/desktop/src/auth/two-factor-v1.component.ts delete mode 100644 apps/web/src/app/auth/two-factor-options-v1.component.html delete mode 100644 apps/web/src/app/auth/two-factor-options-v1.component.ts delete mode 100644 apps/web/src/app/auth/two-factor-v1.component.html delete mode 100644 apps/web/src/app/auth/two-factor-v1.component.ts delete mode 100644 libs/angular/src/auth/components/two-factor-options-v1.component.ts delete mode 100644 libs/angular/src/auth/components/two-factor-v1.component.spec.ts delete mode 100644 libs/angular/src/auth/components/two-factor-v1.component.ts delete mode 100644 libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2c940ccdf5a..8c47db0d331 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1391,24 +1391,12 @@ "premiumRequiredDesc": { "message": "A Premium membership is required to use this feature." }, - "enterVerificationCodeApp": { - "message": "Enter the 6 digit verification code from your authenticator app." - }, "authenticationTimeout": { "message": "Authentication timeout" }, "authenticationSessionTimedOut": { "message": "The authentication session timed out. Please restart the login process." }, - "enterVerificationCodeEmail": { - "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", - "placeholders": { - "email": { - "content": "$1", - "example": "example@gmail.com" - } - } - }, "verificationCodeEmailSent": { "message": "Verification email sent to $EMAIL$.", "placeholders": { @@ -1418,18 +1406,9 @@ } } }, - "rememberMe": { - "message": "Remember me" - }, "dontAskAgainOnThisDeviceFor30Days": { "message": "Don't ask again on this device for 30 days" }, - "sendVerificationCodeEmailAgain": { - "message": "Send verification code email again" - }, - "useAnotherTwoStepMethod": { - "message": "Use another two-step login method" - }, "selectAnotherMethod": { "message": "Select another method", "description": "Select another two-step login method" @@ -1437,18 +1416,9 @@ "useYourRecoveryCode": { "message": "Use your recovery code" }, - "insertYubiKey": { - "message": "Insert your YubiKey into your computer's USB port, then touch its button." - }, "insertU2f": { "message": "Insert your security key into your computer's USB port. If it has a button, touch it." }, - "webAuthnNewTab": { - "message": "To start the WebAuthn 2FA verification. Click the button below to open a new tab and follow the instructions provided in the new tab." - }, - "webAuthnNewTabOpen": { - "message": "Open new tab" - }, "openInNewTab": { "message": "Open in new tab" }, @@ -3848,15 +3818,9 @@ "duoHealthCheckResultsInNullAuthUrlError": { "message": "Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance." }, - "launchDuoAndFollowStepsToFinishLoggingIn": { - "message": "Launch Duo and follow the steps to finish logging in." - }, "duoRequiredForAccount": { "message": "Duo two-step login is required for your account." }, - "popoutTheExtensionToCompleteLogin": { - "message": "Popout the extension to complete login." - }, "popoutExtension": { "message": "Popout extension" }, diff --git a/apps/browser/src/auth/popup/two-factor-options-v1.component.html b/apps/browser/src/auth/popup/two-factor-options-v1.component.html deleted file mode 100644 index f25944aba65..00000000000 --- a/apps/browser/src/auth/popup/two-factor-options-v1.component.html +++ /dev/null @@ -1,29 +0,0 @@ -
-
- -
-

- {{ "twoStepOptions" | i18n }} -

-
-
-
-
-
- - -
-
-
diff --git a/apps/browser/src/auth/popup/two-factor-options-v1.component.ts b/apps/browser/src/auth/popup/two-factor-options-v1.component.ts deleted file mode 100644 index 0c71421fc04..00000000000 --- a/apps/browser/src/auth/popup/two-factor-options-v1.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; - -import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options-v1.component"; -import { - TwoFactorProviderDetails, - TwoFactorService, -} from "@bitwarden/common/auth/abstractions/two-factor.service"; -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"; - -@Component({ - selector: "app-two-factor-options", - templateUrl: "two-factor-options-v1.component.html", -}) -export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponent { - constructor( - twoFactorService: TwoFactorService, - router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - private activatedRoute: ActivatedRoute, - ) { - super(twoFactorService, router, i18nService, platformUtilsService, window, environmentService); - } - - close() { - this.navigateTo2FA(); - } - - override async choose(p: TwoFactorProviderDetails) { - await super.choose(p); - await this.twoFactorService.setSelectedProvider(p.type); - - this.navigateTo2FA(); - } - - navigateTo2FA() { - const sso = this.activatedRoute.snapshot.queryParamMap.get("sso") === "true"; - - if (sso) { - // Persist SSO flag back to the 2FA comp if it exists - // in order for successful login logic to work properly for - // SSO + 2FA in browser extension - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["2fa"], { queryParams: { sso: true } }); - } else { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["2fa"]); - } - } -} diff --git a/apps/browser/src/auth/popup/two-factor-v1.component.html b/apps/browser/src/auth/popup/two-factor-v1.component.html deleted file mode 100644 index 126b0ea5a99..00000000000 --- a/apps/browser/src/auth/popup/two-factor-v1.component.html +++ /dev/null @@ -1,196 +0,0 @@ -
-
-
- -
-

- {{ title }} -

-
- -
-
-
- - -
- - {{ "enterVerificationCodeApp" | i18n }} - - - {{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }} - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-

{{ "insertYubiKey" | i18n }}

- -
-
-
-
- - -
-
- - -
-
-
-
- - -
- -
-
-
-
- - -
-
-
-
- - -
-

{{ "webAuthnNewTab" | i18n }}

- -
-
- - -
-

- {{ "duoRequiredForAccount" | i18n }} -

- -

- {{ "popoutTheExtensionToCompleteLogin" | i18n }} -

- - -

{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}

- - -
-
- - -
-
-
- - -
-
-
-
-
-
- -
-
-

{{ "noTwoStepProviders" | i18n }}

-

{{ "noTwoStepProviders2" | i18n }}

-
- -
- - - - - - - - -

- -

-
-
-
diff --git a/apps/browser/src/auth/popup/two-factor-v1.component.ts b/apps/browser/src/auth/popup/two-factor-v1.component.ts deleted file mode 100644 index 884e42bf73a..00000000000 --- a/apps/browser/src/auth/popup/two-factor-v1.component.ts +++ /dev/null @@ -1,260 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, Subscription, firstValueFrom } from "rxjs"; -import { filter, first, takeUntil } from "rxjs/operators"; - -import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { BrowserApi } from "../../platform/browser/browser-api"; -import { ZonedMessageListenerService } from "../../platform/browser/zoned-message-listener.service"; -import BrowserPopupUtils from "../../platform/popup/browser-popup-utils"; - -import { closeTwoFactorAuthWebAuthnPopout } from "./utils/auth-popout-window"; - -@Component({ - selector: "app-two-factor", - templateUrl: "two-factor-v1.component.html", -}) -export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnInit, OnDestroy { - private destroy$ = new Subject(); - inPopout = BrowserPopupUtils.inPopout(window); - - constructor( - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - i18nService: I18nService, - apiService: ApiService, - platformUtilsService: PlatformUtilsService, - private syncService: SyncService, - environmentService: EnvironmentService, - stateService: StateService, - route: ActivatedRoute, - private messagingService: MessagingService, - logService: LogService, - twoFactorService: TwoFactorService, - appIdService: AppIdService, - loginEmailService: LoginEmailServiceAbstraction, - userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - configService: ConfigService, - ssoLoginService: SsoLoginServiceAbstraction, - private dialogService: DialogService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - toastService: ToastService, - @Inject(WINDOW) protected win: Window, - private browserMessagingApi: ZonedMessageListenerService, - ) { - super( - loginStrategyService, - router, - i18nService, - apiService, - platformUtilsService, - win, - environmentService, - stateService, - route, - logService, - twoFactorService, - appIdService, - loginEmailService, - userDecryptionOptionsService, - ssoLoginService, - configService, - masterPasswordService, - accountService, - toastService, - ); - this.onSuccessfulLogin = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - syncService.fullSync(true); - }; - - this.onSuccessfulLoginTde = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - syncService.fullSync(true); - }; - - this.onSuccessfulLoginTdeNavigate = async () => { - this.win.close(); - }; - - this.successRoute = "/tabs/vault"; - // FIXME: Chromium 110 has broken WebAuthn support in extensions via an iframe - this.webAuthnNewTab = true; - } - - async ngOnInit() { - if (this.route.snapshot.paramMap.has("webAuthnResponse")) { - // WebAuthn fallback response - this.selectedProviderType = TwoFactorProviderType.WebAuthn; - this.token = this.route.snapshot.paramMap.get("webAuthnResponse"); - this.onSuccessfulLogin = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.syncService.fullSync(true); - this.messagingService.send("reloadPopup"); - window.close(); - }; - this.remember = this.route.snapshot.paramMap.get("remember") === "true"; - await this.doSubmit(); - return; - } - - await super.ngOnInit(); - if (this.selectedProviderType == null) { - return; - } - - // WebAuthn prompt appears inside the popup on linux, and requires a larger popup width - // than usual to avoid cutting off the dialog. - if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) { - document.body.classList.add("linux-webauthn"); - } - - if ( - this.selectedProviderType === TwoFactorProviderType.Email && - BrowserPopupUtils.inPopup(window) - ) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "warning" }, - content: { key: "popup2faCloseMessage" }, - type: "warning", - }); - if (confirmed) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserPopupUtils.openCurrentPagePopout(window); - } - } - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - if (qParams.sso === "true") { - this.onSuccessfulLogin = async () => { - // This is not awaited so we don't pause the application while the sync is happening. - // This call is executed by the service that lives in the background script so it will continue - // the sync even if this tab closes. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.syncService.fullSync(true); - - // Force sidebars (FF && Opera) to reload while exempting current window - // because we are just going to close the current window. - BrowserApi.reloadOpenWindows(true); - - // We don't need this window anymore because the intent is for the user to be left - // on the web vault screen which tells them to continue in the browser extension (sidebar or popup) - await closeTwoFactorAuthWebAuthnPopout(); - }; - } - }); - } - - async ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - - if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && (await this.isLinux())) { - document.body.classList.remove("linux-webauthn"); - } - super.ngOnDestroy(); - } - - anotherMethod() { - const sso = this.route.snapshot.queryParamMap.get("sso") === "true"; - - if (sso) { - // We must persist this so when the user returns to the 2FA comp, the - // proper onSuccessfulLogin logic is executed. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["2fa-options"], { queryParams: { sso: true } }); - } else { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["2fa-options"]); - } - } - - async popoutCurrentPage() { - await BrowserPopupUtils.openCurrentPagePopout(window); - } - - async isLinux() { - return (await BrowserApi.getPlatformInfo()).os === "linux"; - } - - duoResultSubscription: Subscription; - protected override setupDuoResultListener() { - if (!this.duoResultSubscription) { - this.duoResultSubscription = this.browserMessagingApi - .messageListener$() - .pipe( - filter((msg: any) => msg.command === "duoResult"), - takeUntil(this.destroy$), - ) - .subscribe((msg: { command: string; code: string; state: string }) => { - this.token = msg.code + "|" + msg.state; - // This floating promise is intentional. We don't need to await the submit + awaiting in a subscription is not recommended. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.submit(); - }); - } - } - - override async launchDuoFrameless() { - if (this.duoFramelessUrl === null) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), - }); - return; - } - - const duoHandOffMessage = { - title: this.i18nService.t("youSuccessfullyLoggedIn"), - message: this.i18nService.t("youMayCloseThisWindow"), - isCountdown: false, - }; - - // we're using the connector here as a way to set a cookie with translations - // before continuing to the duo frameless url - const env = await firstValueFrom(this.environmentService.environment$); - const launchUrl = - env.getWebVaultUrl() + - "/duo-redirect-connector.html" + - "?duoFramelessUrl=" + - encodeURIComponent(this.duoFramelessUrl) + - "&handOffMessage=" + - encodeURIComponent(JSON.stringify(duoHandOffMessage)); - this.platformUtilsService.launchUri(launchUrl); - } -} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index e73f56fa2f6..9948b450e17 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -7,7 +7,6 @@ import { EnvironmentSelectorRouteData, ExtensionDefaultOverlayPosition, } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { activeAuthGuard, authGuard, @@ -59,8 +58,6 @@ import { import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; -import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component"; -import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { Fido2Component } from "../autofill/popup/fido2/fido2.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; @@ -142,32 +139,6 @@ const routes: Routes = [ canActivate: [fido2AuthGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, - ...unauthUiRefreshSwap( - TwoFactorComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "2fa", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "2fa", - canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard], - children: [ - { - path: "", - component: TwoFactorAuthComponent, - }, - ], - data: { - elevation: 1, - pageTitle: { - key: "verifyYourIdentity", - }, - showBackButton: true, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - }, - ), { path: "", component: ExtensionAnonLayoutWrapperComponent, @@ -191,12 +162,6 @@ const routes: Routes = [ }, ], }, - { - path: "2fa-options", - component: TwoFactorOptionsComponentV1, - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 } satisfies RouteDataProperties, - }, { path: "device-verification", component: ExtensionAnonLayoutWrapperComponent, @@ -371,7 +336,6 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, - { path: "", component: ExtensionAnonLayoutWrapperComponent, @@ -567,6 +531,23 @@ const routes: Routes = [ }, ], }, + { + path: "2fa", + canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard], + children: [ + { + path: "", + component: TwoFactorAuthComponent, + }, + ], + data: { + elevation: 1, + pageTitle: { + key: "verifyYourIdentity", + }, + showBackButton: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + }, ], }, { diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 80cffa03b17..b2542679e06 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -25,8 +25,6 @@ import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { VaultTimeoutInputComponent } from "../auth/popup/settings/vault-timeout-input.component"; import { SsoComponentV1 } from "../auth/popup/sso-v1.component"; -import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component"; -import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; @@ -93,8 +91,6 @@ import "../platform/popup/locales"; SetPasswordComponent, SsoComponentV1, TabsV2Component, - TwoFactorComponentV1, - TwoFactorOptionsComponentV1, UpdateTempPasswordComponent, UserVerificationComponent, VaultTimeoutInputComponent, diff --git a/apps/browser/src/popup/scss/misc.scss b/apps/browser/src/popup/scss/misc.scss index d1308d26180..8aace90d0a6 100644 --- a/apps/browser/src/popup/scss/misc.scss +++ b/apps/browser/src/popup/scss/misc.scss @@ -103,16 +103,6 @@ p.lead { margin: 0 !important; } -.no-vmargin { - margin-top: 0 !important; - margin-bottom: 0 !important; -} - -.no-vpad { - padding-top: 0 !important; - padding-bottom: 0 !important; -} - .display-block { display: block !important; } diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 3bb130d321d..cd5064a87e4 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -6,7 +6,6 @@ import { DesktopDefaultOverlayPosition, EnvironmentSelectorComponent, } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, lockGuard, @@ -53,7 +52,6 @@ import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.compo import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; -import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; @@ -76,28 +74,6 @@ const routes: Routes = [ children: [], // Children lets us have an empty component. canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, - ...unauthUiRefreshSwap( - TwoFactorComponentV1, - AnonLayoutWrapperComponent, - { - path: "2fa", - }, - { - path: "2fa", - canActivate: [unauthGuardFn(), TwoFactorAuthGuard], - children: [ - { - path: "", - component: TwoFactorAuthComponent, - }, - ], - data: { - pageTitle: { - key: "verifyYourIdentity", - }, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - }, - ), { path: "authentication-timeout", component: AnonLayoutWrapperComponent, @@ -360,6 +336,21 @@ const routes: Routes = [ }, } satisfies AnonLayoutWrapperData, }, + { + path: "2fa", + canActivate: [unauthGuardFn(), TwoFactorAuthGuard], + children: [ + { + path: "", + component: TwoFactorAuthComponent, + }, + ], + data: { + pageTitle: { + key: "verifyYourIdentity", + }, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, ], }, ]; diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index b1b2864af5a..b717afe4a41 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -16,8 +16,6 @@ import { LoginModule } from "../auth/login/login.module"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; import { SsoComponentV1 } from "../auth/sso-v1.component"; -import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component"; -import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { SshAgentService } from "../autofill/services/ssh-agent.service"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; @@ -78,9 +76,7 @@ import { SharedModule } from "./shared/shared.module"; SetPasswordComponent, SettingsComponent, ShareComponent, - TwoFactorComponentV1, SsoComponentV1, - TwoFactorOptionsComponentV1, UpdateTempPasswordComponent, VaultComponent, VaultTimeoutInputComponent, diff --git a/apps/desktop/src/auth/two-factor-options-v1.component.html b/apps/desktop/src/auth/two-factor-options-v1.component.html deleted file mode 100644 index 6f87c666215..00000000000 --- a/apps/desktop/src/auth/two-factor-options-v1.component.html +++ /dev/null @@ -1,33 +0,0 @@ - diff --git a/apps/desktop/src/auth/two-factor-options-v1.component.ts b/apps/desktop/src/auth/two-factor-options-v1.component.ts deleted file mode 100644 index 1cb440a5f5f..00000000000 --- a/apps/desktop/src/auth/two-factor-options-v1.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component } from "@angular/core"; -import { Router } from "@angular/router"; - -import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponentV1 } from "@bitwarden/angular/auth/components/two-factor-options-v1.component"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -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"; - -@Component({ - selector: "app-two-factor-options", - templateUrl: "two-factor-options-v1.component.html", -}) -export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponentV1 { - constructor( - twoFactorService: TwoFactorService, - router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - ) { - super(twoFactorService, router, i18nService, platformUtilsService, window, environmentService); - } -} diff --git a/apps/desktop/src/auth/two-factor-v1.component.html b/apps/desktop/src/auth/two-factor-v1.component.html deleted file mode 100644 index 1f65d5070f6..00000000000 --- a/apps/desktop/src/auth/two-factor-v1.component.html +++ /dev/null @@ -1,175 +0,0 @@ -
-
-
- Bitwarden -

{{ title }}

- -
- - -

- {{ "enterVerificationCodeApp" | i18n }} -

-

- {{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }} -

-
-
- - -
-
- - - {{ "sendVerificationCodeEmailAgain" | i18n }} - - -
- - - -

{{ "insertYubiKey" | i18n }}

- - - - - -
-
-
- - -
-
-
-
- - - -
-
- -
-
-
- - - -
- - {{ "duoRequiredByOrgForAccount" | i18n }} - - {{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }} -
-
-
- -
-
-
-

{{ "noTwoStepProviders" | i18n }}

-

{{ "noTwoStepProviders2" | i18n }}

-
-
-
- -
-
-
- - -
-
-
- - -
- -
- - -
-
- -
-
- -
-
- -
-
-
- -
-
-
-
- diff --git a/apps/desktop/src/auth/two-factor-v1.component.ts b/apps/desktop/src/auth/two-factor-v1.component.ts deleted file mode 100644 index 13c7d0a452b..00000000000 --- a/apps/desktop/src/auth/two-factor-v1.component.ts +++ /dev/null @@ -1,190 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Inject, NgZone, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; - -import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ToastService } from "@bitwarden/components"; - -import { TwoFactorOptionsComponentV1 } from "./two-factor-options-v1.component"; - -const BroadcasterSubscriptionId = "TwoFactorComponent"; - -@Component({ - selector: "app-two-factor", - templateUrl: "two-factor-v1.component.html", -}) -export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnDestroy { - @ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true }) - twoFactorOptionsModal: ViewContainerRef; - - showingModal = false; - duoCallbackSubscriptionEnabled: boolean = false; - - constructor( - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - i18nService: I18nService, - apiService: ApiService, - platformUtilsService: PlatformUtilsService, - syncService: SyncService, - environmentService: EnvironmentService, - private broadcasterService: BroadcasterService, - private modalService: ModalService, - stateService: StateService, - private ngZone: NgZone, - route: ActivatedRoute, - logService: LogService, - twoFactorService: TwoFactorService, - appIdService: AppIdService, - loginEmailService: LoginEmailServiceAbstraction, - userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - configService: ConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - toastService: ToastService, - @Inject(WINDOW) protected win: Window, - ) { - super( - loginStrategyService, - router, - i18nService, - apiService, - platformUtilsService, - win, - environmentService, - stateService, - route, - logService, - twoFactorService, - appIdService, - loginEmailService, - userDecryptionOptionsService, - ssoLoginService, - configService, - masterPasswordService, - accountService, - toastService, - ); - this.onSuccessfulLogin = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - syncService.fullSync(true); - }; - - this.onSuccessfulLoginTde = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - syncService.fullSync(true); - }; - } - - async anotherMethod() { - const [modal, childComponent] = await this.modalService.openViewRef( - TwoFactorOptionsComponentV1, - this.twoFactorOptionsModal, - ); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - modal.onShown.subscribe(() => { - this.showingModal = true; - }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - modal.onClosed.subscribe(() => { - this.showingModal = false; - }); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - childComponent.onProviderSelected.subscribe(async (provider: TwoFactorProviderType) => { - modal.close(); - this.selectedProviderType = provider; - await this.init(); - }); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - childComponent.onRecoverSelected.subscribe(() => { - modal.close(); - }); - } - - async submit() { - await super.submit(); - if (this.captchaSiteKey) { - const content = document.getElementById("content") as HTMLDivElement; - content.setAttribute("style", "width:335px"); - } - } - - protected override setupDuoResultListener() { - if (!this.duoCallbackSubscriptionEnabled) { - this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { - await this.ngZone.run(async () => { - if (message.command === "duoCallback") { - this.token = message.code + "|" + message.state; - await this.submit(); - } - }); - }); - this.duoCallbackSubscriptionEnabled = true; - } - } - - override async launchDuoFrameless() { - if (this.duoFramelessUrl === null) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), - }); - return; - } - - const duoHandOffMessage = { - title: this.i18nService.t("youSuccessfullyLoggedIn"), - message: this.i18nService.t("youMayCloseThisWindow"), - isCountdown: false, - }; - - // we're using the connector here as a way to set a cookie with translations - // before continuing to the duo frameless url - const env = await firstValueFrom(this.environmentService.environment$); - const launchUrl = - env.getWebVaultUrl() + - "/duo-redirect-connector.html" + - "?duoFramelessUrl=" + - encodeURIComponent(this.duoFramelessUrl) + - "&handOffMessage=" + - encodeURIComponent(JSON.stringify(duoHandOffMessage)); - this.platformUtilsService.launchUri(launchUrl); - } - - ngOnDestroy(): void { - if (this.duoCallbackSubscriptionEnabled) { - this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - this.duoCallbackSubscriptionEnabled = false; - } - } -} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 07404b37fcd..42eebd98223 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -833,18 +833,6 @@ "continue": { "message": "Continue" }, - "enterVerificationCodeApp": { - "message": "Enter the 6 digit verification code from your authenticator app." - }, - "enterVerificationCodeEmail": { - "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", - "placeholders": { - "email": { - "content": "$1", - "example": "example@gmail.com" - } - } - }, "verificationCodeEmailSent": { "message": "Verification email sent to $EMAIL$.", "placeholders": { @@ -854,18 +842,9 @@ } } }, - "rememberMe": { - "message": "Remember me" - }, "dontAskAgainOnThisDeviceFor30Days": { "message": "Don't ask again on this device for 30 days" }, - "sendVerificationCodeEmailAgain": { - "message": "Send verification code email again" - }, - "useAnotherTwoStepMethod": { - "message": "Use another two-step login method" - }, "selectAnotherMethod": { "message": "Select another method", "description": "Select another two-step login method" @@ -873,9 +852,6 @@ "useYourRecoveryCode": { "message": "Use your recovery code" }, - "insertYubiKey": { - "message": "Insert your YubiKey into your computer's USB port, then touch its button." - }, "insertU2f": { "message": "Insert your security key into your computer's USB port. If it has a button, touch it." }, @@ -3224,9 +3200,6 @@ "duoHealthCheckResultsInNullAuthUrlError": { "message": "Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance." }, - "launchDuoAndFollowStepsToFinishLoggingIn": { - "message": "Launch Duo and follow the steps to finish logging in." - }, "duoRequiredByOrgForAccount": { "message": "Duo two-step login is required for your account." }, diff --git a/apps/desktop/src/scss/pages.scss b/apps/desktop/src/scss/pages.scss index b9559e13a26..ecb36aae662 100644 --- a/apps/desktop/src/scss/pages.scss +++ b/apps/desktop/src/scss/pages.scss @@ -27,7 +27,6 @@ #accessibility-cookie-page, #register-page, #hint-page, -#two-factor-page, #update-temp-password-page, #remove-password-page { padding-top: 20px; @@ -48,7 +47,6 @@ #accessibility-cookie-page, #register-page, #hint-page, -#two-factor-page, #lock-page, #update-temp-password-page { .content { diff --git a/apps/web/src/app/auth/two-factor-options-v1.component.html b/apps/web/src/app/auth/two-factor-options-v1.component.html deleted file mode 100644 index 43c054060ea..00000000000 --- a/apps/web/src/app/auth/two-factor-options-v1.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - - {{ "twoStepOptions" | i18n }} - - -
-
-
- -
-
-

{{ p.name }}

-

{{ p.description }}

-
-
- -
-
-
-
-
-
-
- -
-
-

{{ "recoveryCodeTitle" | i18n }}

-

{{ "recoveryCodeDesc" | i18n }}

-
-
- -
-
-
-
- - - -
diff --git a/apps/web/src/app/auth/two-factor-options-v1.component.ts b/apps/web/src/app/auth/two-factor-options-v1.component.ts deleted file mode 100644 index 08665dcfcdd..00000000000 --- a/apps/web/src/app/auth/two-factor-options-v1.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { DialogRef } from "@angular/cdk/dialog"; -import { Component } from "@angular/core"; -import { Router } from "@angular/router"; - -import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponentV1 } from "@bitwarden/angular/auth/components/two-factor-options-v1.component"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -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 { DialogService } from "@bitwarden/components"; - -export enum TwoFactorOptionsDialogResult { - Provider = "Provider selected", - Recover = "Recover selected", -} - -export type TwoFactorOptionsDialogResultType = { - result: TwoFactorOptionsDialogResult; - type: TwoFactorProviderType; -}; - -@Component({ - selector: "app-two-factor-options", - templateUrl: "two-factor-options-v1.component.html", -}) -export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponentV1 { - constructor( - twoFactorService: TwoFactorService, - router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - private dialogRef: DialogRef, - ) { - super(twoFactorService, router, i18nService, platformUtilsService, window, environmentService); - } - - async choose(p: any) { - await super.choose(p); - this.dialogRef.close({ result: TwoFactorOptionsDialogResult.Provider, type: p.type }); - } - - async recover() { - await super.recover(); - this.dialogRef.close({ result: TwoFactorOptionsDialogResult.Recover }); - } - - static open(dialogService: DialogService) { - return dialogService.open(TwoFactorOptionsComponentV1); - } -} diff --git a/apps/web/src/app/auth/two-factor-v1.component.html b/apps/web/src/app/auth/two-factor-v1.component.html deleted file mode 100644 index b78747e04c2..00000000000 --- a/apps/web/src/app/auth/two-factor-v1.component.html +++ /dev/null @@ -1,106 +0,0 @@ -
-
- -

- {{ "enterVerificationCodeApp" | i18n }} -

-

- {{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }} -

- - {{ "verificationCode" | i18n }} - - - - {{ "sendVerificationCodeEmailAgain" | i18n }} - - -
- -

{{ "insertYubiKey" | i18n }}

- - - - - - - {{ "verificationCode" | i18n }} - - -
- -
- -
-
- - -

- {{ "duoRequiredByOrgForAccount" | i18n }} -

-

{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}

-
- - {{ "rememberMe" | i18n }} - - - -

{{ "noTwoStepProviders" | i18n }}

-

{{ "noTwoStepProviders2" | i18n }}

-
-
-
- -
- -
- - - - {{ "cancel" | i18n }} - -
- -
-
diff --git a/apps/web/src/app/auth/two-factor-v1.component.ts b/apps/web/src/app/auth/two-factor-v1.component.ts deleted file mode 100644 index 9a9fab02de3..00000000000 --- a/apps/web/src/app/auth/two-factor-v1.component.ts +++ /dev/null @@ -1,164 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Inject, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil, lastValueFrom } from "rxjs"; - -import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { DialogService, ToastService } from "@bitwarden/components"; - -import { - TwoFactorOptionsDialogResult, - TwoFactorOptionsComponentV1, - TwoFactorOptionsDialogResultType, -} from "./two-factor-options-v1.component"; - -@Component({ - selector: "app-two-factor", - templateUrl: "two-factor-v1.component.html", -}) -export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnInit, OnDestroy { - @ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true }) - twoFactorOptionsModal: ViewContainerRef; - formGroup = this.formBuilder.group({ - token: [ - "", - { - validators: [Validators.required], - updateOn: "submit", - }, - ], - remember: [false], - }); - private destroy$ = new Subject(); - constructor( - loginStrategyService: LoginStrategyServiceAbstraction, - router: Router, - i18nService: I18nService, - apiService: ApiService, - platformUtilsService: PlatformUtilsService, - stateService: StateService, - environmentService: EnvironmentService, - private dialogService: DialogService, - route: ActivatedRoute, - logService: LogService, - twoFactorService: TwoFactorService, - appIdService: AppIdService, - loginEmailService: LoginEmailServiceAbstraction, - userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - configService: ConfigService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - accountService: AccountService, - toastService: ToastService, - private formBuilder: FormBuilder, - @Inject(WINDOW) protected win: Window, - ) { - super( - loginStrategyService, - router, - i18nService, - apiService, - platformUtilsService, - win, - environmentService, - stateService, - route, - logService, - twoFactorService, - appIdService, - loginEmailService, - userDecryptionOptionsService, - ssoLoginService, - configService, - masterPasswordService, - accountService, - toastService, - ); - this.onSuccessfulLoginNavigate = this.goAfterLogIn; - } - async ngOnInit() { - await super.ngOnInit(); - this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { - this.token = value.token; - this.remember = value.remember; - }); - } - submitForm = async () => { - await this.submit(); - }; - - async anotherMethod() { - const dialogRef = TwoFactorOptionsComponentV1.open(this.dialogService); - const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed); - if (response.result === TwoFactorOptionsDialogResult.Provider) { - this.selectedProviderType = response.type; - await this.init(); - } - } - - protected override handleMigrateEncryptionKey(result: AuthResult): boolean { - if (!result.requiresEncryptionKeyMigration) { - return false; - } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["migrate-legacy-encryption"]); - return true; - } - - goAfterLogIn = async () => { - this.loginEmailService.clearValues(); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.successRoute], { - queryParams: { - identifier: this.orgIdentifier, - }, - }); - }; - - private duoResultChannel: BroadcastChannel; - - protected override setupDuoResultListener() { - if (!this.duoResultChannel) { - this.duoResultChannel = new BroadcastChannel("duoResult"); - this.duoResultChannel.addEventListener("message", this.handleDuoResultMessage); - } - } - - private handleDuoResultMessage = async (msg: { data: { code: string; state: string } }) => { - this.token = msg.data.code + "|" + msg.data.state; - await this.submit(); - }; - - async ngOnDestroy() { - super.ngOnDestroy(); - - if (this.duoResultChannel) { - // clean up duo listener if it was initialized. - this.duoResultChannel.removeEventListener("message", this.handleDuoResultMessage); - this.duoResultChannel.close(); - } - } -} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index c531f358b34..0334519516a 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -2,7 +2,6 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; -import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { authGuard, lockGuard, @@ -65,7 +64,6 @@ import { AccountComponent } from "./auth/settings/account/account.component"; import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; -import { TwoFactorComponentV1 } from "./auth/two-factor-v1.component"; import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component"; @@ -378,51 +376,28 @@ const routes: Routes = [ }, ], }, - ...unauthUiRefreshSwap( - TwoFactorComponentV1, - TwoFactorAuthComponent, - { - path: "2fa", - canActivate: [unauthGuardFn()], - children: [ - { - path: "", - component: TwoFactorComponentV1, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - data: { - pageTitle: { - key: "verifyYourIdentity", - }, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - }, - { - path: "2fa", - canActivate: [unauthGuardFn(), TwoFactorAuthGuard], - children: [ - { - path: "", - component: TwoFactorAuthComponent, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - data: { - pageTitle: { - key: "verifyYourIdentity", - }, - titleAreaMaxWidth: "md", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - }, - ), + { + path: "2fa", + component: TwoFactorAuthComponent, + canActivate: [unauthGuardFn(), TwoFactorAuthGuard], + children: [ + { + path: "", + component: TwoFactorAuthComponent, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageTitle: { + key: "verifyYourIdentity", + }, + titleAreaMaxWidth: "md", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "lock", canActivate: [deepLinkGuard(), lockGuard()], diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index f21a9338491..70dbf63f1f8 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -43,8 +43,6 @@ import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor- import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { SsoComponentV1 } from "../auth/sso-v1.component"; -import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component"; -import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdatePasswordComponent } from "../auth/update-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"; @@ -148,12 +146,10 @@ import { SharedModule } from "./shared.module"; SetPasswordComponent, SponsoredFamiliesComponent, SponsoringOrgRowComponent, - TwoFactorComponentV1, SsoComponentV1, TwoFactorSetupAuthenticatorComponent, TwoFactorSetupDuoComponent, TwoFactorSetupEmailComponent, - TwoFactorOptionsComponentV1, TwoFactorRecoveryComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent, @@ -210,12 +206,10 @@ import { SharedModule } from "./shared.module"; SetPasswordComponent, SponsoredFamiliesComponent, SponsoringOrgRowComponent, - TwoFactorComponentV1, SsoComponentV1, TwoFactorSetupAuthenticatorComponent, TwoFactorSetupDuoComponent, TwoFactorSetupEmailComponent, - TwoFactorOptionsComponentV1, TwoFactorSetupComponent, TwoFactorVerifyComponent, TwoFactorSetupWebAuthnComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b9ead1df44d..4133062a047 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1456,18 +1456,6 @@ } } }, - "enterVerificationCodeApp": { - "message": "Enter the 6 digit verification code from your authenticator app." - }, - "enterVerificationCodeEmail": { - "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", - "placeholders": { - "email": { - "content": "$1", - "example": "example@gmail.com" - } - } - }, "verificationCodeEmailSent": { "message": "Verification email sent to $EMAIL$.", "placeholders": { @@ -1477,18 +1465,10 @@ } } }, - "rememberMe": { - "message": "Remember me" - }, + "dontAskAgainOnThisDeviceFor30Days": { "message": "Don't ask again on this device for 30 days" }, - "sendVerificationCodeEmailAgain": { - "message": "Send verification code email again" - }, - "useAnotherTwoStepMethod": { - "message": "Use another two-step login method" - }, "selectAnotherMethod": { "message": "Select another method", "description": "Select another two-step login method" @@ -1496,9 +1476,6 @@ "useYourRecoveryCode": { "message": "Use your recovery code" }, - "insertYubiKey": { - "message": "Insert your YubiKey into your computer's USB port, then touch its button." - }, "insertU2f": { "message": "Insert your security key into your computer's USB port. If it has a button, touch it." }, @@ -7273,9 +7250,6 @@ "duoHealthCheckResultsInNullAuthUrlError": { "message": "Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance." }, - "launchDuoAndFollowStepsToFinishLoggingIn": { - "message": "Launch Duo and follow the steps to finish logging in." - }, "duoRequiredByOrgForAccount": { "message": "Duo two-step login is required for your account." }, diff --git a/libs/angular/src/auth/components/two-factor-options-v1.component.ts b/libs/angular/src/auth/components/two-factor-options-v1.component.ts deleted file mode 100644 index f02eabcc156..00000000000 --- a/libs/angular/src/auth/components/two-factor-options-v1.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Directive, EventEmitter, OnInit, Output } from "@angular/core"; -import { Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; - -import { - TwoFactorProviderDetails, - TwoFactorService, -} from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -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"; - -@Directive() -export class TwoFactorOptionsComponentV1 implements OnInit { - @Output() onProviderSelected = new EventEmitter(); - @Output() onRecoverSelected = new EventEmitter(); - - providers: any[] = []; - - constructor( - protected twoFactorService: TwoFactorService, - protected router: Router, - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - protected win: Window, - protected environmentService: EnvironmentService, - ) {} - - async ngOnInit() { - this.providers = await this.twoFactorService.getSupportedProviders(this.win); - } - - async choose(p: TwoFactorProviderDetails) { - this.onProviderSelected.emit(p.type); - } - - async recover() { - const env = await firstValueFrom(this.environmentService.environment$); - const webVault = env.getWebVaultUrl(); - this.platformUtilsService.launchUri(webVault + "/#/recover-2fa"); - this.onRecoverSelected.emit(); - } -} diff --git a/libs/angular/src/auth/components/two-factor-v1.component.spec.ts b/libs/angular/src/auth/components/two-factor-v1.component.spec.ts deleted file mode 100644 index 47075acc758..00000000000 --- a/libs/angular/src/auth/components/two-factor-v1.component.spec.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { Component } from "@angular/core"; -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ActivatedRoute, convertToParamMap, Router } from "@angular/router"; -import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; - -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption, - FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption, - FakeUserDecryptionOptions as UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; -import { UserId } from "@bitwarden/common/types/guid"; -import { ToastService } from "@bitwarden/components"; - -import { TwoFactorComponentV1 } from "./two-factor-v1.component"; - -// test component that extends the TwoFactorComponent -@Component({}) -class TestTwoFactorComponent extends TwoFactorComponentV1 {} - -interface TwoFactorComponentProtected { - trustedDeviceEncRoute: string; - changePasswordRoute: string; - forcePasswordResetRoute: string; - successRoute: string; -} - -describe("TwoFactorComponent", () => { - let component: TestTwoFactorComponent; - let _component: TwoFactorComponentProtected; - - let fixture: ComponentFixture; - const userId = "userId" as UserId; - - // Mock Services - let mockLoginStrategyService: MockProxy; - let mockRouter: MockProxy; - let mockI18nService: MockProxy; - let mockApiService: MockProxy; - let mockPlatformUtilsService: MockProxy; - let mockWin: MockProxy; - let mockEnvironmentService: MockProxy; - let mockStateService: MockProxy; - let mockLogService: MockProxy; - let mockTwoFactorService: MockProxy; - let mockAppIdService: MockProxy; - let mockLoginEmailService: MockProxy; - let mockUserDecryptionOptionsService: MockProxy; - let mockSsoLoginService: MockProxy; - let mockConfigService: MockProxy; - let mockMasterPasswordService: FakeMasterPasswordService; - let mockAccountService: FakeAccountService; - let mockToastService: MockProxy; - - let mockUserDecryptionOpts: { - noMasterPassword: UserDecryptionOptions; - withMasterPassword: UserDecryptionOptions; - withMasterPasswordAndTrustedDevice: UserDecryptionOptions; - withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions; - withMasterPasswordAndKeyConnector: UserDecryptionOptions; - noMasterPasswordWithTrustedDevice: UserDecryptionOptions; - noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions; - noMasterPasswordWithKeyConnector: UserDecryptionOptions; - }; - - let selectedUserDecryptionOptions: BehaviorSubject; - let authenticationSessionTimeoutSubject: BehaviorSubject; - - beforeEach(() => { - authenticationSessionTimeoutSubject = new BehaviorSubject(false); - mockLoginStrategyService = mock(); - mockLoginStrategyService.authenticationSessionTimeout$ = authenticationSessionTimeoutSubject; - mockRouter = mock(); - mockI18nService = mock(); - mockApiService = mock(); - mockPlatformUtilsService = mock(); - mockWin = mock(); - mockEnvironmentService = mock(); - mockStateService = mock(); - mockLogService = mock(); - mockTwoFactorService = mock(); - mockAppIdService = mock(); - mockLoginEmailService = mock(); - mockUserDecryptionOptionsService = mock(); - mockSsoLoginService = mock(); - mockConfigService = mock(); - mockAccountService = mockAccountServiceWith(userId); - mockToastService = mock(); - mockMasterPasswordService = new FakeMasterPasswordService(); - - mockUserDecryptionOpts = { - noMasterPassword: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: undefined, - keyConnectorOption: undefined, - }), - withMasterPassword: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: undefined, - keyConnectorOption: undefined, - }), - withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false), - keyConnectorOption: undefined, - }), - withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false), - keyConnectorOption: undefined, - }), - withMasterPasswordAndKeyConnector: new UserDecryptionOptions({ - hasMasterPassword: true, - trustedDeviceOption: undefined, - keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), - }), - noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false, false), - keyConnectorOption: undefined, - }), - noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true, false), - keyConnectorOption: undefined, - }), - noMasterPasswordWithKeyConnector: new UserDecryptionOptions({ - hasMasterPassword: false, - trustedDeviceOption: undefined, - keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), - }), - }; - - selectedUserDecryptionOptions = new BehaviorSubject( - mockUserDecryptionOpts.withMasterPassword, - ); - mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions; - - TestBed.configureTestingModule({ - declarations: [TestTwoFactorComponent], - providers: [ - { provide: LoginStrategyServiceAbstraction, useValue: mockLoginStrategyService }, - { provide: Router, useValue: mockRouter }, - { provide: I18nService, useValue: mockI18nService }, - { provide: ApiService, useValue: mockApiService }, - { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, - { provide: WINDOW, useValue: mockWin }, - { provide: EnvironmentService, useValue: mockEnvironmentService }, - { provide: StateService, useValue: mockStateService }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - // Default to standard 2FA flow - not SSO + 2FA - queryParamMap: convertToParamMap({ sso: "false" }), - }, - }, - }, - { provide: LogService, useValue: mockLogService }, - { provide: TwoFactorService, useValue: mockTwoFactorService }, - { provide: AppIdService, useValue: mockAppIdService }, - { provide: LoginEmailServiceAbstraction, useValue: mockLoginEmailService }, - { - provide: UserDecryptionOptionsServiceAbstraction, - useValue: mockUserDecryptionOptionsService, - }, - { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, - { provide: ConfigService, useValue: mockConfigService }, - { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, - { provide: AccountService, useValue: mockAccountService }, - { provide: ToastService, useValue: mockToastService }, - ], - }); - - fixture = TestBed.createComponent(TestTwoFactorComponent); - component = fixture.componentInstance; - _component = component as any; - }); - - afterEach(() => { - // Reset all mocks after each test - jest.resetAllMocks(); - }); - - it("should create", () => { - expect(component).toBeTruthy(); - }); - - // Shared tests - const testChangePasswordOnSuccessfulLogin = () => { - it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => { - // Act - await component.doSubmit(); - - // Assert - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], { - queryParams: { - identifier: component.orgIdentifier, - }, - }); - }); - }; - - const testForceResetOnSuccessfulLogin = (reasonString: string) => { - it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => { - // Act - await component.doSubmit(); - - // expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], { - queryParams: { - identifier: component.orgIdentifier, - }, - }); - }); - }; - - describe("Standard 2FA scenarios", () => { - describe("doSubmit", () => { - const token = "testToken"; - const remember = false; - const captchaToken = "testCaptchaToken"; - - beforeEach(() => { - component.token = token; - component.remember = remember; - component.captchaToken = captchaToken; - - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); - }); - - it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => { - // Arrange - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - - // Act - await component.doSubmit(); - - // Assert - expect(mockLoginStrategyService.logInTwoFactor).toHaveBeenCalledWith( - new TokenTwoFactorRequest(component.selectedProviderType, token, remember), - captchaToken, - ); - }); - - it("should return when handleCaptchaRequired returns true", async () => { - // Arrange - const captchaSiteKey = "testCaptchaSiteKey"; - const authResult = new AuthResult(); - authResult.captchaSiteKey = captchaSiteKey; - - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - - // Note: the any casts are required b/c typescript cant recognize that - // handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited - // from the CaptchaProtectedComponent - const handleCaptchaRequiredSpy = jest - .spyOn(component, "handleCaptchaRequired") - .mockReturnValue(true); - - // Act - const result = await component.doSubmit(); - - // Assert - expect(handleCaptchaRequiredSpy).toHaveBeenCalled(); - expect(result).toBeUndefined(); - }); - - it("calls onSuccessfulLogin when defined", async () => { - // Arrange - component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined); - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - - // Act - await component.doSubmit(); - - // Assert - expect(component.onSuccessfulLogin).toHaveBeenCalled(); - }); - - it("calls loginEmailService.clearValues() when login is successful", async () => { - // Arrange - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - // spy on loginEmailService.clearValues - const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues"); - - // Act - await component.doSubmit(); - - // Assert - expect(clearValuesSpy).toHaveBeenCalled(); - }); - - describe("Set Master Password scenarios", () => { - beforeEach(() => { - const authResult = new AuthResult(); - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - describe("Given user needs to set a master password", () => { - beforeEach(() => { - // Only need to test the case where the user has no master password to test the primary change mp flow here - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword); - }); - - testChangePasswordOnSuccessfulLogin(); - }); - - it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => { - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.noMasterPasswordWithKeyConnector, - ); - - await component.doSubmit(); - - expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], { - queryParams: { - identifier: component.orgIdentifier, - }, - }); - }); - }); - - describe("Force Master Password Reset scenarios", () => { - [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ].forEach((forceResetPasswordReason) => { - const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; - - beforeEach(() => { - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); - - const authResult = new AuthResult(); - authResult.forcePasswordReset = forceResetPasswordReason; - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - testForceResetOnSuccessfulLogin(reasonString); - }); - }); - - it("calls onSuccessfulLoginNavigate when the callback is defined", async () => { - // Arrange - component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined); - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - - // Act - await component.doSubmit(); - - // Assert - expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled(); - }); - - it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => { - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - - // Act - await component.doSubmit(); - - // Assert - expect(component.onSuccessfulLoginNavigate).not.toBeDefined(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); - }); - }); - }); - - describe("SSO > 2FA scenarios", () => { - beforeEach(() => { - const mockActivatedRoute = TestBed.inject(ActivatedRoute); - mockActivatedRoute.snapshot.queryParamMap.get = jest.fn().mockReturnValue("true"); - }); - - describe("doSubmit", () => { - const token = "testToken"; - const remember = false; - const captchaToken = "testCaptchaToken"; - - beforeEach(() => { - component.token = token; - component.remember = remember; - component.captchaToken = captchaToken; - }); - - describe("Trusted Device Encryption scenarios", () => { - beforeEach(() => { - mockConfigService.getFeatureFlag.mockResolvedValue(true); - }); - - describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => { - beforeEach(() => { - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword, - ); - - const authResult = new AuthResult(); - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - it("navigates to the component's defined trusted device encryption route and sets correct flag when user doesn't have a MP and key connector isn't enabled", async () => { - // Act - await component.doSubmit(); - - // Assert - expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - userId, - ); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith( - [_component.trustedDeviceEncRoute], - undefined, - ); - }); - }); - - describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => { - [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ].forEach((forceResetPasswordReason) => { - const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; - - beforeEach(() => { - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, - ); - - const authResult = new AuthResult(); - authResult.forcePasswordReset = forceResetPasswordReason; - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - testForceResetOnSuccessfulLogin(reasonString); - }); - }); - - describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => { - let authResult; - beforeEach(() => { - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, - ); - - authResult = new AuthResult(); - authResult.forcePasswordReset = ForceSetPasswordReason.None; - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => { - await component.doSubmit(); - - expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith( - [_component.trustedDeviceEncRoute], - undefined, - ); - }); - - it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { - component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined); - - await component.doSubmit(); - - expect(mockRouter.navigate).not.toHaveBeenCalled(); - expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled(); - }); - }); - }); - }); - }); - - it("navigates to the timeout route when timeout expires", async () => { - authenticationSessionTimeoutSubject.next(true); - - expect(mockRouter.navigate).toHaveBeenCalledWith(["authentication-timeout"]); - }); -}); diff --git a/libs/angular/src/auth/components/two-factor-v1.component.ts b/libs/angular/src/auth/components/two-factor-v1.component.ts deleted file mode 100644 index 3fda2685f5e..00000000000 --- a/libs/angular/src/auth/components/two-factor-v1.component.ts +++ /dev/null @@ -1,514 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Directive, Inject, OnInit, OnDestroy } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; - -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { - LoginStrategyServiceAbstraction, - LoginEmailServiceAbstraction, - TrustedDeviceUserDecryptionOption, - UserDecryptionOptions, - UserDecryptionOptionsServiceAbstraction, -} from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; -import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; -import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; -import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; -import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; -import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; -import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; -import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { ToastService } from "@bitwarden/components"; - -import { CaptchaProtectedComponent } from "./captcha-protected.component"; - -@Directive() -export class TwoFactorComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy { - token = ""; - remember = false; - webAuthnReady = false; - webAuthnNewTab = false; - providers = TwoFactorProviders; - providerType = TwoFactorProviderType; - selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator; - webAuthnSupported = false; - webAuthn: WebAuthnIFrame = null; - title = ""; - twoFactorEmail: string = null; - formPromise: Promise; - emailPromise: Promise; - orgIdentifier: string = null; - - duoFramelessUrl: string = null; - duoResultListenerInitialized = false; - - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; - - onSuccessfulLoginTde: () => Promise; - onSuccessfulLoginTdeNavigate: () => Promise; - - protected loginRoute = "login"; - - protected trustedDeviceEncRoute = "login-initiated"; - protected changePasswordRoute = "set-password"; - protected forcePasswordResetRoute = "update-temp-password"; - protected successRoute = "vault"; - protected twoFactorTimeoutRoute = "authentication-timeout"; - - get isDuoProvider(): boolean { - return ( - this.selectedProviderType === TwoFactorProviderType.Duo || - this.selectedProviderType === TwoFactorProviderType.OrganizationDuo - ); - } - - constructor( - protected loginStrategyService: LoginStrategyServiceAbstraction, - protected router: Router, - protected i18nService: I18nService, - protected apiService: ApiService, - protected platformUtilsService: PlatformUtilsService, - @Inject(WINDOW) protected win: Window, - protected environmentService: EnvironmentService, - protected stateService: StateService, - protected route: ActivatedRoute, - protected logService: LogService, - protected twoFactorService: TwoFactorService, - protected appIdService: AppIdService, - protected loginEmailService: LoginEmailServiceAbstraction, - protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, - protected ssoLoginService: SsoLoginServiceAbstraction, - protected configService: ConfigService, - protected masterPasswordService: InternalMasterPasswordServiceAbstraction, - protected accountService: AccountService, - protected toastService: ToastService, - ) { - super(environmentService, i18nService, platformUtilsService, toastService); - - this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); - - // Add subscription to authenticationSessionTimeout$ and navigate to twoFactorTimeoutRoute if expired - this.loginStrategyService.authenticationSessionTimeout$ - .pipe(takeUntilDestroyed()) - .subscribe(async (expired) => { - if (!expired) { - return; - } - - try { - await this.router.navigate([this.twoFactorTimeoutRoute]); - } catch (err) { - this.logService.error(`Failed to navigate to ${this.twoFactorTimeoutRoute} route`, err); - } - }); - } - - async ngOnInit() { - if (!(await this.authing()) || (await this.twoFactorService.getProviders()) == null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.loginRoute]); - return; - } - - this.route.queryParams.pipe(first()).subscribe((qParams) => { - if (qParams.identifier != null) { - this.orgIdentifier = qParams.identifier; - } - }); - - if (await this.needsLock()) { - this.successRoute = "lock"; - } - - if (this.win != null && this.webAuthnSupported) { - const env = await firstValueFrom(this.environmentService.environment$); - const webVaultUrl = env.getWebVaultUrl(); - this.webAuthn = new WebAuthnIFrame( - this.win, - webVaultUrl, - this.webAuthnNewTab, - this.platformUtilsService, - this.i18nService, - (token: string) => { - this.token = token; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.submit(); - }, - (error: string) => { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("webauthnCancelOrTimeout"), - }); - }, - (info: string) => { - if (info === "ready") { - this.webAuthnReady = true; - } - }, - ); - } - - this.selectedProviderType = await this.twoFactorService.getDefaultProvider( - this.webAuthnSupported, - ); - await this.init(); - } - - ngOnDestroy(): void { - this.cleanupWebAuthn(); - this.webAuthn = null; - } - - async init() { - if (this.selectedProviderType == null) { - this.title = this.i18nService.t("loginUnavailable"); - return; - } - - this.cleanupWebAuthn(); - this.title = (TwoFactorProviders as any)[this.selectedProviderType].name; - const providerData = await this.twoFactorService.getProviders().then((providers) => { - return providers.get(this.selectedProviderType); - }); - switch (this.selectedProviderType) { - case TwoFactorProviderType.WebAuthn: - if (!this.webAuthnNewTab) { - setTimeout(async () => { - await this.authWebAuthn(); - }, 500); - } - break; - case TwoFactorProviderType.Duo: - case TwoFactorProviderType.OrganizationDuo: - // Setup listener for duo-redirect.ts connector to send back the code - if (!this.duoResultListenerInitialized) { - // setup client specific duo result listener - this.setupDuoResultListener(); - this.duoResultListenerInitialized = true; - } - // flow must be launched by user so they can choose to remember the device or not. - this.duoFramelessUrl = providerData.AuthUrl; - break; - case TwoFactorProviderType.Email: - this.twoFactorEmail = providerData.Email; - if ((await this.twoFactorService.getProviders()).size > 1) { - await this.sendEmail(false); - } - break; - default: - break; - } - } - - async submit() { - await this.setupCaptcha(); - - if (this.token == null || this.token === "") { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("verificationCodeRequired"), - }); - return; - } - - if (this.selectedProviderType === TwoFactorProviderType.WebAuthn) { - if (this.webAuthn != null) { - this.webAuthn.stop(); - } else { - return; - } - } else if ( - this.selectedProviderType === TwoFactorProviderType.Email || - this.selectedProviderType === TwoFactorProviderType.Authenticator - ) { - this.token = this.token.replace(" ", "").trim(); - } - - await this.doSubmit(); - if (this.selectedProviderType === TwoFactorProviderType.WebAuthn && this.webAuthn != null) { - this.webAuthn.start(); - } - } - - async doSubmit() { - this.formPromise = this.loginStrategyService.logInTwoFactor( - new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember), - this.captchaToken, - ); - const authResult: AuthResult = await this.formPromise; - - await this.handleLoginResponse(authResult); - } - - protected handleMigrateEncryptionKey(result: AuthResult): boolean { - if (!result.requiresEncryptionKeyMigration) { - return false; - } - - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccured"), - message: this.i18nService.t("encryptionKeyMigrationRequired"), - }); - return true; - } - - // Each client will have own implementation - protected setupDuoResultListener(): void {} - - private async handleLoginResponse(authResult: AuthResult) { - if (this.handleCaptchaRequired(authResult)) { - return; - } else if (this.handleMigrateEncryptionKey(authResult)) { - return; - } - - // Save off the OrgSsoIdentifier for use in the TDE flows - // - TDE login decryption options component - // - Browser SSO on extension open - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier, userId); - this.loginEmailService.clearValues(); - - // note: this flow affects both TDE & standard users - if (this.isForcePasswordResetRequired(authResult)) { - return await this.handleForcePasswordReset(this.orgIdentifier); - } - - const userDecryptionOpts = await firstValueFrom( - this.userDecryptionOptionsService.userDecryptionOptions$, - ); - - const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption); - - if (tdeEnabled) { - return await this.handleTrustedDeviceEncryptionEnabled( - authResult, - this.orgIdentifier, - userDecryptionOpts, - ); - } - - // User must set password if they don't have one and they aren't using either TDE or key connector. - const requireSetPassword = - !userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined; - - if (requireSetPassword || authResult.resetMasterPassword) { - // Change implies going no password -> password in this case - return await this.handleChangePasswordRequired(this.orgIdentifier); - } - - return await this.handleSuccessfulLogin(); - } - - private async isTrustedDeviceEncEnabled( - trustedDeviceOption: TrustedDeviceUserDecryptionOption, - ): Promise { - const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true"; - - return ssoTo2faFlowActive && trustedDeviceOption !== undefined; - } - - private async handleTrustedDeviceEncryptionEnabled( - authResult: AuthResult, - orgIdentifier: string, - userDecryptionOpts: UserDecryptionOptions, - ): Promise { - // If user doesn't have a MP, but has reset password permission, they must set a MP - if ( - !userDecryptionOpts.hasMasterPassword && - userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission - ) { - // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) - // Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and - // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.masterPasswordService.setForceSetPasswordReason( - ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, - userId, - ); - } - - if (this.onSuccessfulLoginTde != null) { - // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete - // before navigating to the success route. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulLoginTde(); - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.navigateViaCallbackOrRoute( - this.onSuccessfulLoginTdeNavigate, - // Navigate to TDE page (if user was on trusted device and TDE has decrypted - // their user key, the login-initiated guard will redirect them to the vault) - [this.trustedDeviceEncRoute], - ); - } - - private async handleChangePasswordRequired(orgIdentifier: string) { - await this.router.navigate([this.changePasswordRoute], { - queryParams: { - identifier: orgIdentifier, - }, - }); - } - - /** - * Determines if a user needs to reset their password based on certain conditions. - * Users can be forced to reset their password via an admin or org policy disallowing weak passwords. - * Note: this is different from the SSO component login flow as a user can - * login with MP and then have to pass 2FA to finish login and we can actually - * evaluate if they have a weak password at that time. - * - * @param {AuthResult} authResult - The authentication result. - * @returns {boolean} Returns true if a password reset is required, false otherwise. - */ - private isForcePasswordResetRequired(authResult: AuthResult): boolean { - const forceResetReasons = [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ]; - - return forceResetReasons.includes(authResult.forcePasswordReset); - } - - private async handleForcePasswordReset(orgIdentifier: string) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate([this.forcePasswordResetRoute], { - queryParams: { - identifier: orgIdentifier, - }, - }); - } - - private async handleSuccessfulLogin() { - if (this.onSuccessfulLogin != null) { - // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete - // before navigating to the success route. - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.onSuccessfulLogin(); - } - await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]); - } - - private async navigateViaCallbackOrRoute( - callback: () => Promise, - commands: unknown[], - extras?: NavigationExtras, - ): Promise { - if (callback) { - await callback(); - } else { - await this.router.navigate(commands, extras); - } - } - - async sendEmail(doToast: boolean) { - if (this.selectedProviderType !== TwoFactorProviderType.Email) { - return; - } - - if (this.emailPromise != null) { - return; - } - - if ((await this.loginStrategyService.getEmail()) == null) { - this.toastService.showToast({ - variant: "error", - title: this.i18nService.t("errorOccurred"), - message: this.i18nService.t("sessionTimeout"), - }); - return; - } - - try { - const request = new TwoFactorEmailRequest(); - request.email = await this.loginStrategyService.getEmail(); - request.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); - request.ssoEmail2FaSessionToken = - await this.loginStrategyService.getSsoEmail2FaSessionToken(); - request.deviceIdentifier = await this.appIdService.getAppId(); - request.authRequestAccessCode = await this.loginStrategyService.getAccessCode(); - request.authRequestId = await this.loginStrategyService.getAuthRequestId(); - this.emailPromise = this.apiService.postTwoFactorEmail(request); - await this.emailPromise; - if (doToast) { - this.toastService.showToast({ - variant: "success", - title: null, - message: this.i18nService.t("verificationCodeEmailSent", this.twoFactorEmail), - }); - } - } catch (e) { - this.logService.error(e); - } - - this.emailPromise = null; - } - - async authWebAuthn() { - const providerData = await this.twoFactorService.getProviders().then((providers) => { - return providers.get(this.selectedProviderType); - }); - - if (!this.webAuthnSupported || this.webAuthn == null) { - return; - } - - this.webAuthn.init(providerData); - } - - private cleanupWebAuthn() { - if (this.webAuthn != null) { - this.webAuthn.stop(); - this.webAuthn.cleanup(); - } - } - - private async authing(): Promise { - return (await firstValueFrom(this.loginStrategyService.currentAuthType$)) !== null; - } - - private async needsLock(): Promise { - const authType = await firstValueFrom(this.loginStrategyService.currentAuthType$); - return authType == AuthenticationType.Sso || authType == AuthenticationType.UserApiKey; - } - - async launchDuoFrameless() { - if (this.duoFramelessUrl === null) { - this.toastService.showToast({ - variant: "error", - title: null, - message: this.i18nService.t("duoHealthCheckResultsInNullAuthUrlError"), - }); - return; - } - - this.platformUtilsService.launchUri(this.duoFramelessUrl); - } -} diff --git a/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts b/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts deleted file mode 100644 index b19e73a7412..00000000000 --- a/libs/angular/src/auth/functions/unauth-ui-refresh-route-swap.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Type, inject } from "@angular/core"; -import { Route, Routes } from "@angular/router"; - -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; - -import { componentRouteSwap } from "../../utils/component-route-swap"; - -/** - * Helper function to swap between two components based on the UnauthenticatedExtensionUIRefresh feature flag. - * We need this because the auth teams's authenticated UI will be refreshed as part of the MVP but the - * unauthenticated UIs will not necessarily make the cut. - * Note: Even though this is primarily an extension refresh initiative, this will be used across clients - * as we are consolidating the unauthenticated UIs into single libs/auth components which affects all clients. - * @param defaultComponent - The current non-refreshed component to render. - * @param refreshedComponent - The new refreshed component to render. - * @param options - The shared route options to apply to both components. - * @param altOptions - The alt route options to apply to the alt component. If not provided, the base options will be used. - */ -export function unauthUiRefreshSwap( - defaultComponent: Type, - refreshedComponent: Type, - options: Route, - altOptions?: Route, -): Routes { - return componentRouteSwap( - defaultComponent, - refreshedComponent, - async () => { - const configService = inject(ConfigService); - return configService.getFeatureFlag(FeatureFlag.UnauthenticatedExtensionUIRefresh); - }, - options, - altOptions, - ); -} From f759e62aeb9e10ac0bcc7aba6e892025e6ebdb9d Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Fri, 28 Mar 2025 14:17:18 -0400 Subject: [PATCH 042/228] fix(browser): restore timer based background syncs (#14031) * docs: fix a typo * fix(browser): restore timer-based background syncs The browser extension was not performing scheduled background syncs every 30 minutes as expected. This was due to missing task scheduling code that was accidentally removed during the web push implementation (PR #11346). This commit: - Creates a new BackgroundSyncService to manage sync scheduling - Properly initializes the sync interval in main.background.ts - Adds a test to ensure the sync initialization code isn't accidentally removed again - Organizes platform module structure to support the new service Fixes PM-19396 * review: remove unecassary await keyword --- .../src/background/main.background.spec.ts | 13 +++ .../browser/src/background/main.background.ts | 10 +- apps/browser/tsconfig.json | 1 + .../scheduling/task-scheduler.service.ts | 2 +- .../background-sync.service.spec.ts | 107 ++++++++++++++++++ .../background-sync.service.ts | 44 +++++++ libs/platform/src/background-sync/index.ts | 1 + libs/platform/src/index.ts | 1 + libs/platform/tsconfig.json | 4 +- tsconfig.json | 1 + 10 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 apps/browser/src/background/main.background.spec.ts create mode 100644 libs/platform/src/background-sync/background-sync.service.spec.ts create mode 100644 libs/platform/src/background-sync/background-sync.service.ts create mode 100644 libs/platform/src/background-sync/index.ts diff --git a/apps/browser/src/background/main.background.spec.ts b/apps/browser/src/background/main.background.spec.ts new file mode 100644 index 00000000000..c2cd38b7a30 --- /dev/null +++ b/apps/browser/src/background/main.background.spec.ts @@ -0,0 +1,13 @@ +// This test skips all the initilization of the background script and just +// focuses on making sure we don't accidently delete the initilization of +// background vault syncing. This has happened before! +describe("MainBackground sync task scheduling", () => { + it("includes code to schedule the sync interval task", () => { + // Get the bootstrap method source code as string + const { default: MainBackground } = jest.requireActual("./main.background"); + const bootstrapSource = MainBackground.prototype.bootstrap.toString(); + + // Check that the source includes the critical sync interval scheduling code + expect(bootstrapSource).toContain("this.backgroundSyncService.init();"); + }); +}); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e1f0b8bfc64..cae554c872c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -127,7 +127,6 @@ import { WebPushNotificationsApiService, WorkerWebPushConnectionService, } from "@bitwarden/common/platform/notifications/internal"; -import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; @@ -222,6 +221,7 @@ import { KdfConfigService, KeyService as KeyServiceAbstraction, } from "@bitwarden/key-management"; +import { BackgroundSyncService } from "@bitwarden/platform/background-sync"; import { IndividualVaultExportService, IndividualVaultExportServiceAbstraction, @@ -391,6 +391,7 @@ export default class MainBackground { offscreenDocumentService: OffscreenDocumentService; syncServiceListener: SyncServiceListener; browserInitialInstallService: BrowserInitialInstallService; + backgroundSyncService: BackgroundSyncService; webPushConnectionService: WorkerWebPushConnectionService | UnsupportedWebPushConnectionService; themeStateService: DefaultThemeStateService; @@ -585,9 +586,9 @@ export default class MainBackground { this.logService, this.stateProvider, ); - this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.scheduleNextSyncInterval, () => - this.fullSync(), - ); + + this.backgroundSyncService = new BackgroundSyncService(this.taskSchedulerService); + this.backgroundSyncService.register(() => this.fullSync()); this.environmentService = new BrowserEnvironmentService( this.logService, @@ -1368,6 +1369,7 @@ export default class MainBackground { setTimeout(async () => { await this.refreshBadge(); await this.fullSync(false); + this.backgroundSyncService.init(); this.notificationsService.startListening(); resolve(); }, 500); diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index e836af1327c..e24985f58af 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -29,6 +29,7 @@ "@bitwarden/key-management": ["../../libs/key-management/src"], "@bitwarden/key-management-ui": ["../../libs/key-management-ui/src"], "@bitwarden/platform": ["../../libs/platform/src"], + "@bitwarden/platform/*": ["../../libs/platform/src/*"], "@bitwarden/send-ui": ["../../libs/tools/send/send-ui/src"], "@bitwarden/tools-card": ["../../libs/tools/card/src"], "@bitwarden/ui-common": ["../../libs/ui/common/src"], diff --git a/libs/common/src/platform/scheduling/task-scheduler.service.ts b/libs/common/src/platform/scheduling/task-scheduler.service.ts index 35a577dda7d..3ea80c3ee0a 100644 --- a/libs/common/src/platform/scheduling/task-scheduler.service.ts +++ b/libs/common/src/platform/scheduling/task-scheduler.service.ts @@ -11,7 +11,7 @@ import { ScheduledTaskName } from "./scheduled-task-name.enum"; * in the future but the task that is ran is NOT the remainder of your RXJS pipeline. The * task you want ran must instead be registered in a location reachable on a service worker * startup (on browser). An example of an acceptible location is the constructor of a service - * you know is created in `MainBackground`. Uses of this API is other clients _can_ have the + * you know is created in `MainBackground`. Uses of this API in other clients _can_ have the * `registerTaskHandler` call in more places, but in order to have it work across clients * it is recommended to register it according to the rules of browser. * diff --git a/libs/platform/src/background-sync/background-sync.service.spec.ts b/libs/platform/src/background-sync/background-sync.service.spec.ts new file mode 100644 index 00000000000..1deb907b151 --- /dev/null +++ b/libs/platform/src/background-sync/background-sync.service.spec.ts @@ -0,0 +1,107 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; + +import { BackgroundSyncService, DEFAULT_SYNC_INTERVAL_MS } from "./background-sync.service"; + +describe("BackgroundSyncService", () => { + let taskSchedulerService: MockProxy; + let backgroundSyncService: BackgroundSyncService; + + beforeEach(() => { + taskSchedulerService = mock(); + backgroundSyncService = new BackgroundSyncService(taskSchedulerService); + }); + + describe("register", () => { + it("registers a task handler with the correct task name", () => { + // Arrange + const syncCallback = jest.fn().mockResolvedValue(undefined); + + // Act + backgroundSyncService.register(syncCallback); + + // Assert + expect(taskSchedulerService.registerTaskHandler).toHaveBeenCalledTimes(1); + expect(taskSchedulerService.registerTaskHandler).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + syncCallback, + ); + }); + }); + + describe("init", () => { + it("schedules the sync interval task with default interval", () => { + // Act + backgroundSyncService.init(); + + // Assert + expect(taskSchedulerService.setInterval).toHaveBeenCalledTimes(1); + expect(taskSchedulerService.setInterval).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + DEFAULT_SYNC_INTERVAL_MS, + ); + }); + + it("schedules the sync interval task with custom interval", () => { + // Arrange + const customInterval = 60000; // 1 minute + + // Act + backgroundSyncService.init(customInterval); + + // Assert + expect(taskSchedulerService.setInterval).toHaveBeenCalledTimes(1); + expect(taskSchedulerService.setInterval).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + customInterval, + ); + }); + + it("correctly handles zero interval by using default", () => { + // Act + backgroundSyncService.init(0); + + // Assert + expect(taskSchedulerService.setInterval).toHaveBeenCalledTimes(1); + expect(taskSchedulerService.setInterval).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + DEFAULT_SYNC_INTERVAL_MS, + ); + }); + + it("correctly handles negative interval by using default", () => { + // Act + backgroundSyncService.init(-1000); + + // Assert + expect(taskSchedulerService.setInterval).toHaveBeenCalledTimes(1); + expect(taskSchedulerService.setInterval).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + DEFAULT_SYNC_INTERVAL_MS, + ); + }); + }); + + describe("full integration", () => { + it("registers and initializes correctly in sequence", () => { + // Arrange + const syncCallback = jest.fn().mockResolvedValue(undefined); + const customInterval = 45000; // 45 seconds + + // Act + backgroundSyncService.register(syncCallback); + backgroundSyncService.init(customInterval); + + // Assert + expect(taskSchedulerService.registerTaskHandler).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + syncCallback, + ); + expect(taskSchedulerService.setInterval).toHaveBeenCalledWith( + ScheduledTaskNames.scheduleNextSyncInterval, + customInterval, + ); + }); + }); +}); diff --git a/libs/platform/src/background-sync/background-sync.service.ts b/libs/platform/src/background-sync/background-sync.service.ts new file mode 100644 index 00000000000..dc1b49d399e --- /dev/null +++ b/libs/platform/src/background-sync/background-sync.service.ts @@ -0,0 +1,44 @@ +import { TaskSchedulerService, ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; + +/** + * The default interval between background syncs. + * 300,000ms = 5 minutes + */ +export const DEFAULT_SYNC_INTERVAL_MS = 300000; + +/** + * Service responsible for registering and managing background synchronization for the browser extension. + * Handles scheduling of periodic sync operations using the task scheduler infrastructure. + */ + +export class BackgroundSyncService { + /** + * Creates a new instance of BackgroundSyncService. + * @param taskSchedulerService - Service that handles scheduling and execution of periodic tasks + */ + constructor(private taskSchedulerService: TaskSchedulerService) {} + + /** + * Registers a callback function to be executed when the sync interval task is triggered. + * This associates the sync task name with the provided callback in the task scheduler. + * + * @param syncCallback - The function to execute when the sync task is triggered + */ + register(syncCallback: () => Promise) { + this.taskSchedulerService.registerTaskHandler( + ScheduledTaskNames.scheduleNextSyncInterval, + syncCallback, + ); + } + + /** + * Initializes the background sync service by scheduling the sync interval task. + * This sets up a recurring timer that triggers the registered sync callback at regular intervals. + * + * @param intervalMs - The interval in milliseconds between sync operations (defaults to 300000ms/5 minutes) + */ + init(intervalMs: number = DEFAULT_SYNC_INTERVAL_MS) { + intervalMs = intervalMs < 1 ? DEFAULT_SYNC_INTERVAL_MS : intervalMs; + this.taskSchedulerService.setInterval(ScheduledTaskNames.scheduleNextSyncInterval, intervalMs); + } +} diff --git a/libs/platform/src/background-sync/index.ts b/libs/platform/src/background-sync/index.ts new file mode 100644 index 00000000000..adfeec608be --- /dev/null +++ b/libs/platform/src/background-sync/index.ts @@ -0,0 +1 @@ +export * from "./background-sync.service"; diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index f11ec102845..3fabe3fad1a 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -1 +1,2 @@ export * from "./services/browser-service"; +export * from "./background-sync"; diff --git a/libs/platform/tsconfig.json b/libs/platform/tsconfig.json index eaa021247d8..898f9e41c6a 100644 --- a/libs/platform/tsconfig.json +++ b/libs/platform/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../shared/tsconfig", "compilerOptions": { - "paths": {} + "paths": { + "@bitwarden/common/*": ["../common/src/*"] + } }, "include": ["src", "spec"], "exclude": ["node_modules", "dist"] diff --git a/tsconfig.json b/tsconfig.json index fb50f1e7033..c82851d50c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,7 @@ "@bitwarden/key-management-ui": ["./libs/key-management-ui/src"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/platform": ["./libs/platform/src"], + "@bitwarden/platform/*": ["./libs/platform/src/*"], "@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"], "@bitwarden/tools-card": ["./libs/tools/card/src"], "@bitwarden/ui-common": ["./libs/ui/common/src"], From 66a914badfd65046ce5969163e957ef8f919f5a8 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Fri, 28 Mar 2025 15:50:30 -0400 Subject: [PATCH 043/228] [PM-19654] add hideIcon option to extension anon layout (#14045) --- .../extension-anon-layout-wrapper.component.html | 1 + .../extension-anon-layout-wrapper.component.ts | 10 ++++++++++ .../extension-anon-layout-wrapper.stories.ts | 2 ++ .../anon-layout/anon-layout.component.html | 2 +- .../angular/anon-layout/anon-layout.component.ts | 1 + .../angular/anon-layout/anon-layout.stories.ts | 16 ++++++++++++++++ 6 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index 88a3b1c3076..54cb5203a87 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -21,6 +21,7 @@ [hideLogo]="true" [maxWidth]="maxWidth" [hideFooter]="hideFooter" + [hideIcon]="hideIcon" > diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index 10ef65d0654..51dbb6503d7 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -26,6 +26,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData { showBackButton?: boolean; showLogo?: boolean; hideFooter?: boolean; + hideIcon?: boolean; } @Component({ @@ -48,6 +49,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected showAcctSwitcher: boolean; protected showBackButton: boolean; protected showLogo: boolean = true; + protected hideIcon: boolean = false; protected pageTitle: string; protected pageSubtitle: string; @@ -129,6 +131,10 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { if (firstChildRouteData["showLogo"] !== undefined) { this.showLogo = Boolean(firstChildRouteData["showLogo"]); } + + if (firstChildRouteData["hideIcon"] !== undefined) { + this.hideIcon = Boolean(firstChildRouteData["hideIcon"]); + } } private listenForServiceDataChanges() { @@ -180,6 +186,10 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { if (data.showLogo !== undefined) { this.showLogo = data.showLogo; } + + if (data.hideIcon !== undefined) { + this.hideIcon = data.hideIcon; + } } private handleStringOrTranslation(value: string | Translation): string { diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 841eefda0ad..a0990485d49 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -242,6 +242,7 @@ const initialData: ExtensionAnonLayoutWrapperData = { showAcctSwitcher: true, showBackButton: true, showLogo: true, + hideIcon: false, }; const changedData: ExtensionAnonLayoutWrapperData = { @@ -255,6 +256,7 @@ const changedData: ExtensionAnonLayoutWrapperData = { showAcctSwitcher: false, showBackButton: false, showLogo: false, + hideIcon: false, }; @Component({ diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 4120ea59002..f31a5500b43 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -17,7 +17,7 @@ class="tw-text-center tw-mb-4 sm:tw-mb-6" [ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }" > -
+
diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.ts b/libs/auth/src/angular/anon-layout/anon-layout.component.ts index 05ddb9614f1..1ca4ccd2432 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.ts @@ -39,6 +39,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { @Input() showReadonlyHostname: boolean; @Input() hideLogo: boolean = false; @Input() hideFooter: boolean = false; + @Input() hideIcon: boolean = false; /** * Max width of the title area content diff --git a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts index c7e15d9dcfa..34d561d5210 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts @@ -163,6 +163,22 @@ export const WithCustomIcon: Story = { }), }; +export const HideIcon: Story = { + render: (args) => ({ + props: args, + template: + // Projected content (the
) and styling is just a sample and can be replaced with any content/styling. + ` + +
+
Primary Projected Content Area (customizable)
+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?
+
+
+ `, + }), +}; + export const HideLogo: Story = { render: (args) => ({ props: args, From 907abc9dae3ecb5b994d9e6d852d60576ec64bdb Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Sun, 30 Mar 2025 15:49:52 -0400 Subject: [PATCH 044/228] Complete feature flag grouping by team (#14054) * Completed feature flag grouping * Added organization of default value section. * Clarified comment. --- libs/common/src/enums/feature-flag.enum.ts | 52 +++++++++++++--------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c5119cd5206..1907f6539c5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -2,6 +2,8 @@ * Feature flags. * * Flags MUST be short lived and SHALL be removed once enabled. + * + * Flags should be grouped by team to have visibility of ownership and cleanup. */ export enum FeatureFlag { /* Admin Console Team */ @@ -9,6 +11,11 @@ export enum FeatureFlag { VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint", LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission", SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility", + AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner", + + /* Auth */ + PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence", + UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", /* Autofill */ BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain", @@ -21,6 +28,18 @@ export enum FeatureFlag { NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", + MacOsNativeCredentialSync = "macos-native-credential-sync", + + /* Billing */ + TrialPaymentOptional = "PM-8163-trial-payment", + PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", + PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", + PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", + + /* Key Management */ + PrivateKeyRegeneration = "pm-12241-private-key-regeneration", + UserKeyRotationV2 = "userkey-rotation-v2", + PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", /* Tools */ ItemShare = "item-share", @@ -36,21 +55,7 @@ export enum FeatureFlag { NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss", VaultBulkManagementAction = "vault-bulk-management-action", SecurityTasks = "security-tasks", - - /* Auth */ - PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence", - - UserKeyRotationV2 = "userkey-rotation-v2", - PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", - UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", CipherKeyEncryption = "cipher-key-encryption", - TrialPaymentOptional = "PM-8163-trial-payment", - MacOsNativeCredentialSync = "macos-native-credential-sync", - PrivateKeyRegeneration = "pm-12241-private-key-regeneration", - AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner", - PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal", - PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", - PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -63,6 +68,8 @@ const FALSE = false as boolean; * * DO NOT enable previously disabled flags, REMOVE them instead. * We support true as a value as we prefer flags to "enable" not "disable". + * + * Flags should be grouped by team to have visibility of ownership and cleanup. */ export const DefaultFeatureFlagValue = { /* Admin Console Team */ @@ -70,6 +77,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE, [FeatureFlag.LimitItemDeletion]: FALSE, [FeatureFlag.SsoExternalIdVisibility]: FALSE, + [FeatureFlag.AccountDeprovisioningBanner]: FALSE, /* Autofill */ [FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE, @@ -82,6 +90,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NotificationBarAddLoginImprovements]: FALSE, [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, + [FeatureFlag.MacOsNativeCredentialSync]: FALSE, /* Tools */ [FeatureFlag.ItemShare]: FALSE, @@ -97,21 +106,22 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE, [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.SecurityTasks]: FALSE, + [FeatureFlag.CipherKeyEncryption]: FALSE, /* Auth */ [FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE, - - [FeatureFlag.UserKeyRotationV2]: FALSE, - [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, - [FeatureFlag.CipherKeyEncryption]: FALSE, + + /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.MacOsNativeCredentialSync]: FALSE, - [FeatureFlag.PrivateKeyRegeneration]: FALSE, - [FeatureFlag.AccountDeprovisioningBanner]: FALSE, [FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE, [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, [FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE, + + /* Key Management */ + [FeatureFlag.PrivateKeyRegeneration]: FALSE, + [FeatureFlag.UserKeyRotationV2]: FALSE, + [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; From 56672a3568da7ef0a79de6d18f0ab6a6baab47b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ch=C4=99ci=C5=84ski?= Date: Mon, 31 Mar 2025 12:59:47 +0200 Subject: [PATCH 045/228] [BRE-714] Enhance TestFlight desktop publishing (#13871) * Update TestFlight deployment to use Fastlane for app uploads * Update TestFlight deployment to use Fastlane for app uploads * Fix * Fix create secret for fastlane * Fix create secret for fastlane * Fix create secret for fastlane * Install gsed to use sed on macos runner * Create test file * Fix test * Use actual token * Add TestFlight distribution option for QA testing * Update .github/workflows/build-desktop.yml Co-authored-by: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> * Add if to secret construction for fastlane --------- Co-authored-by: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> --- .github/workflows/build-desktop.yml | 42 ++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 48ecca540e8..72b60da97a1 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -33,6 +33,10 @@ on: description: "Custom SDK branch" required: false type: string + testflight_distribute: + description: "Force distribute to TestFlight regardless of branch (useful for QA testing on feature branches)" + type: boolean + default: true defaults: run: @@ -1208,21 +1212,45 @@ jobs: path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg if-no-files-found: error + - name: Create secrets for Fastlane + if: | + github.event_name != 'pull_request_target' + && (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') + run: | + brew install gsed + + KEY_WITHOUT_NEWLINES=$(gsed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g' ~/private_keys/AuthKey_6TV9MKN3GP.p8) + + cat << EOF > ~/secrets/appstoreconnect-fastlane.json + { + "issuer_id": "${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}", + "key_id": "6TV9MKN3GP", + "key": "$KEY_WITHOUT_NEWLINES" + } + EOF + - name: Deploy to TestFlight id: testflight-deploy if: | github.event_name != 'pull_request_target' - && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') + && (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop') env: APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }} APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP + BRANCH: ${{ github.ref }} run: | - xcrun altool \ - --upload-app \ - --type macos \ - --file "$(find ./dist/mas-universal/Bitwarden*.pkg)" \ - --apiKey $APP_STORE_CONNECT_AUTH_KEY \ - --apiIssuer $APP_STORE_CONNECT_TEAM_ISSUER + + GIT_CHANGE="$(git show -s --format=%s)" + + BRANCH=$(echo $BRANCH | sed 's/refs\/heads\///') + + CHANGELOG="$BRANCH: $GIT_CHANGE" + + fastlane pilot upload \ + --app_identifier "com.bitwarden.desktop" \ + --changelog "$CHANGELOG" \ + --api_key_path $HOME/secrets/appstoreconnect-fastlane.json \ + --pkg "$(find ./dist/mas-universal/Bitwarden*.pkg)" - name: Post message to a Slack channel id: slack-message From 51bfbcf09056775e60fd24b11e37570e46835e85 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:11:47 -0400 Subject: [PATCH 046/228] chore(UI Refresh): [PM-19679] Remove unauth-ui-refresh flag from clients * Completed feature flag grouping * Added organization of default value section. * Clarified comment. * Removed flag * Removed merge error that duplicated comment. --- libs/common/src/enums/feature-flag.enum.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1907f6539c5..798053c09d0 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -15,7 +15,6 @@ export enum FeatureFlag { /* Auth */ PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence", - UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", /* Autofill */ BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain", @@ -110,7 +109,6 @@ export const DefaultFeatureFlagValue = { /* Auth */ [FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE, - [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, From 49924512b87098077440bcd702bd11ef318d19de Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:55:20 +0200 Subject: [PATCH 047/228] [PM-19656] Fix zip option not being set correctly after navigating to Admin Console (#14058) * Simplify if to reduce nesting * Start subscribing to changes of the vaultSelector as soon as possible during ngOnInit --------- Co-authored-by: Daniel James Smith --- .../src/components/export.component.ts | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index aecc6dcc330..c8efe093762 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -225,6 +225,20 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { ), ); + combineLatest([ + this.exportForm.controls.vaultSelector.valueChanges, + this.isExportAttachmentsEnabled$, + ]) + .pipe(takeUntil(this.destroy$)) + .subscribe(([value, isExportAttachmentsEnabled]) => { + this.organizationId = value !== "myVault" ? value : undefined; + + this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip"); + if (value === "myVault" && isExportAttachmentsEnabled) { + this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); + } + }); + merge( this.exportForm.get("format").valueChanges, this.exportForm.get("fileEncryptionType").valueChanges, @@ -322,22 +336,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { takeUntil(this.destroy$), ) .subscribe(); - - combineLatest([ - this.exportForm.controls.vaultSelector.valueChanges, - this.isExportAttachmentsEnabled$, - ]) - .pipe(takeUntil(this.destroy$)) - .subscribe(([value, isExportAttachmentsEnabled]) => { - this.organizationId = value !== "myVault" ? value : undefined; - if (value === "myVault" && isExportAttachmentsEnabled) { - if (!this.formatOptions.some((option) => option.value === "zip")) { - this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); - } - } else { - this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip"); - } - }); } ngAfterViewInit(): void { From 740d0251b8d57ceab78443f6023e86078ecfa481 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:08:03 +0100 Subject: [PATCH 048/228] [PM-19368]Add new collection from individual vault is not displaying Upgrade option rather than Save (#13965) * Resolve the pop up issue and update button * Rename a method properly --- .../collection-dialog.component.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) 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 88746dc708b..1214c0ca411 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 @@ -13,6 +13,8 @@ import { Subject, switchMap, takeUntil, + tap, + filter, } from "rxjs"; import { first } from "rxjs/operators"; @@ -189,10 +191,29 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { this.formGroup.updateValueAndValidity(); } - this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => { - this.organizationSelected.markAsTouched(); - this.formGroup.updateValueAndValidity(); - }); + this.organizationSelected.valueChanges + .pipe( + tap((_) => { + if (this.organizationSelected.errors?.cannotCreateCollections) { + this.buttonDisplayName = ButtonType.Upgrade; + } else { + this.buttonDisplayName = ButtonType.Save; + } + }), + filter(() => this.organizationSelected.errors?.cannotCreateCollections), + switchMap((value) => this.findOrganizationById(value)), + takeUntil(this.destroy$), + ) + .subscribe((org) => { + this.orgExceedingCollectionLimit = org; + this.organizationSelected.markAsTouched(); + this.formGroup.updateValueAndValidity(); + }); + } + + async findOrganizationById(orgId: string): Promise { + const organizations = await firstValueFrom(this.organizations$); + return organizations.find((org) => org.id === orgId); } async loadOrg(orgId: string) { From 646c7198aa2bb7953b6ee69bb619acf2ea9d1b8e Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:10:36 +0100 Subject: [PATCH 049/228] Changes to add validation (#13762) --- .../app/billing/organizations/change-plan-dialog.component.ts | 1 + 1 file changed, 1 insertion(+) 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 dc748e9ee41..a11858f3be8 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 @@ -733,6 +733,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { submit = async () => { if (this.taxComponent !== undefined && !this.taxComponent.validate()) { + this.taxComponent.markAllAsTouched(); return; } From 0311681803f4e97d3ceb15c15efcd63655c3b95c Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:55:04 +0200 Subject: [PATCH 050/228] Fix filename not including "_encrypted_" when selecting encrypted vault exports (#14066) Co-authored-by: Daniel James Smith --- .../src/services/individual-vault-export.service.ts | 2 +- .../vault-export-core/src/services/org-vault-export.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 765de042d32..489a28b4c79 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -250,7 +250,7 @@ export class IndividualVaultExportService return { type: "text/plain", data: JSON.stringify(jsonDoc, null, " "), - fileName: ExportHelper.getFileName("", "json"), + fileName: ExportHelper.getFileName("", "encrypted_json"), } as ExportedVaultAsString; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index f9ecd778c23..8f6494edb70 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -109,7 +109,7 @@ export class OrganizationVaultExportService data: onlyManagedCollections ? await this.getEncryptedManagedExport(organizationId) : await this.getOrganizationEncryptedExport(organizationId), - fileName: ExportHelper.getFileName("org", "json"), + fileName: ExportHelper.getFileName("org", "encrypted_json"), } as ExportedVaultAsString; } From 22039d038d7d88c92247c856f9358fb8e11c018b Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 31 Mar 2025 16:58:02 +0200 Subject: [PATCH 051/228] [PM-3475] Remove deprecated keys (#13266) * Remove deprecated keys * Fix cli build * Fix build --- .../browser/src/background/main.background.ts | 2 - .../service-container/service-container.ts | 2 - .../src/services/jslib-services.module.ts | 2 - .../abstractions/pin.service.abstraction.ts | 13 +- .../pin/pin.service.implementation.ts | 171 ++---------------- .../common/services/pin/pin.service.spec.ts | 164 ++--------------- .../services/master-password.service.ts | 13 -- .../services/vault-timeout.service.ts | 1 - .../platform/abstractions/state.service.ts | 8 - .../src/platform/models/domain/account.ts | 2 - .../src/platform/services/state.service.ts | 41 ----- .../src/abstractions/key.service.ts | 8 - libs/key-management/src/key.service.spec.ts | 8 - libs/key-management/src/key.service.ts | 24 --- 14 files changed, 32 insertions(+), 427 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cae554c872c..eec07b5b1ed 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -655,9 +655,7 @@ export default class MainBackground { this.kdfConfigService, this.keyGenerationService, this.logService, - this.masterPasswordService, this.stateProvider, - this.stateService, ); this.keyService = new DefaultKeyService( diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 5bc07f63c32..6a4651bcd5a 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -436,9 +436,7 @@ export class ServiceContainer { this.kdfConfigService, this.keyGenerationService, this.logService, - this.masterPasswordService, this.stateProvider, - this.stateService, ); this.keyService = new KeyService( diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 37220b5195d..6334e5815d6 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1173,9 +1173,7 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, KeyGenerationServiceAbstraction, LogService, - MasterPasswordServiceAbstraction, StateProvider, - StateServiceAbstraction, ], }), safeProvider({ diff --git a/libs/auth/src/common/abstractions/pin.service.abstraction.ts b/libs/auth/src/common/abstractions/pin.service.abstraction.ts index 0d0f29dff40..16550888b94 100644 --- a/libs/auth/src/common/abstractions/pin.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/pin.service.abstraction.ts @@ -1,4 +1,4 @@ -import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { UserId } from "@bitwarden/common/types/guid"; import { PinKey, UserKey } from "@bitwarden/common/types/key"; import { KdfConfig } from "@bitwarden/key-management"; @@ -90,17 +90,6 @@ export abstract class PinServiceAbstraction { */ abstract clearUserKeyEncryptedPin(userId: UserId): Promise; - /** - * Gets the old MasterKey, encrypted by the PinKey (formerly called `pinProtected`). - * Deprecated and used for migration purposes only. - */ - abstract getOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise; - - /** - * Clears the old MasterKey, encrypted by the PinKey. - */ - abstract clearOldPinKeyEncryptedMasterKey: (userId: UserId) => Promise; - /** * Makes a PinKey from the provided PIN. */ diff --git a/libs/auth/src/common/services/pin/pin.service.implementation.ts b/libs/auth/src/common/services/pin/pin.service.implementation.ts index 0f6ac05f381..99fb725c295 100644 --- a/libs/auth/src/common/services/pin/pin.service.implementation.ts +++ b/libs/auth/src/common/services/pin/pin.service.implementation.ts @@ -4,11 +4,9 @@ import { firstValueFrom, map } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { @@ -18,7 +16,7 @@ import { UserKeyDefinition, } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key"; +import { PinKey, UserKey } from "@bitwarden/common/types/key"; import { KdfConfig, KdfConfigService } from "@bitwarden/key-management"; import { PinServiceAbstraction } from "../../abstractions/pin.service.abstraction"; @@ -73,19 +71,6 @@ export const USER_KEY_ENCRYPTED_PIN = new UserKeyDefinition( }, ); -/** - * The old MasterKey, encrypted by the PinKey (formerly called `pinProtected`). - * Deprecated and used for migration purposes only. - */ -export const OLD_PIN_KEY_ENCRYPTED_MASTER_KEY = new UserKeyDefinition( - PIN_DISK, - "oldPinKeyEncryptedMasterKey", - { - deserializer: (jsonValue) => jsonValue, - clearOn: ["logout"], - }, -); - export class PinService implements PinServiceAbstraction { constructor( private accountService: AccountService, @@ -94,9 +79,7 @@ export class PinService implements PinServiceAbstraction { private kdfConfigService: KdfConfigService, private keyGenerationService: KeyGenerationService, private logService: LogService, - private masterPasswordService: MasterPasswordServiceAbstraction, private stateProvider: StateProvider, - private stateService: StateService, ) {} async getPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise { @@ -190,9 +173,7 @@ export class PinService implements PinServiceAbstraction { this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)), ); const kdfConfig = await this.kdfConfigService.getKdfConfig(); - const pinKey = await this.makePinKey(pin, email, kdfConfig); - return await this.encryptService.encrypt(userKey.key, pinKey); } @@ -242,20 +223,6 @@ export class PinService implements PinServiceAbstraction { return await this.encryptService.encrypt(pin, userKey); } - async getOldPinKeyEncryptedMasterKey(userId: UserId): Promise { - this.validateUserId(userId, "Cannot get oldPinKeyEncryptedMasterKey."); - - return await firstValueFrom( - this.stateProvider.getUserState$(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, userId), - ); - } - - async clearOldPinKeyEncryptedMasterKey(userId: UserId): Promise { - this.validateUserId(userId, "Cannot clear oldPinKeyEncryptedMasterKey."); - - await this.stateProvider.setUserState(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, null, userId); - } - async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise { const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig); return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey; @@ -264,23 +231,13 @@ export class PinService implements PinServiceAbstraction { async getPinLockType(userId: UserId): Promise { this.validateUserId(userId, "Cannot get PinLockType."); - /** - * We can't check the `userKeyEncryptedPin` (formerly called `protectedPin`) for both because old - * accounts only used it for MP on Restart - */ const aUserKeyEncryptedPinIsSet = !!(await this.getUserKeyEncryptedPin(userId)); const aPinKeyEncryptedUserKeyPersistentIsSet = !!(await this.getPinKeyEncryptedUserKeyPersistent(userId)); - const anOldPinKeyEncryptedMasterKeyIsSet = - !!(await this.getOldPinKeyEncryptedMasterKey(userId)); - if (aPinKeyEncryptedUserKeyPersistentIsSet || anOldPinKeyEncryptedMasterKeyIsSet) { + if (aPinKeyEncryptedUserKeyPersistentIsSet) { return "PERSISTENT"; - } else if ( - aUserKeyEncryptedPinIsSet && - !aPinKeyEncryptedUserKeyPersistentIsSet && - !anOldPinKeyEncryptedMasterKeyIsSet - ) { + } else if (aUserKeyEncryptedPinIsSet && !aPinKeyEncryptedUserKeyPersistentIsSet) { return "EPHEMERAL"; } else { return "DISABLED"; @@ -302,7 +259,7 @@ export class PinService implements PinServiceAbstraction { case "DISABLED": return false; case "PERSISTENT": - // The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey or OldPinKeyEncryptedMasterKey set. + // The above getPinLockType call ensures that we have either a PinKeyEncryptedUserKey set. return true; case "EPHEMERAL": { // The above getPinLockType call ensures that we have a UserKeyEncryptedPin set. @@ -326,31 +283,21 @@ export class PinService implements PinServiceAbstraction { try { const pinLockType = await this.getPinLockType(userId); - const requireMasterPasswordOnClientRestart = pinLockType === "EPHEMERAL"; - const { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey } = - await this.getPinKeyEncryptedKeys(pinLockType, userId); + const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedKeys(pinLockType, userId); const email = await firstValueFrom( this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)), ); const kdfConfig = await this.kdfConfigService.getKdfConfig(); - let userKey: UserKey; - - if (oldPinKeyEncryptedMasterKey) { - userKey = await this.decryptAndMigrateOldPinKeyEncryptedMasterKey( - userId, - pin, - email, - kdfConfig, - requireMasterPasswordOnClientRestart, - oldPinKeyEncryptedMasterKey, - ); - } else { - userKey = await this.decryptUserKey(userId, pin, email, kdfConfig, pinKeyEncryptedUserKey); - } - + const userKey: UserKey = await this.decryptUserKey( + userId, + pin, + email, + kdfConfig, + pinKeyEncryptedUserKey, + ); if (!userKey) { this.logService.warning(`User key null after pin key decryption.`); return null; @@ -394,109 +341,23 @@ export class PinService implements PinServiceAbstraction { } /** - * Creates a new `pinKeyEncryptedUserKey` and clears the `oldPinKeyEncryptedMasterKey`. - * @returns UserKey - */ - private async decryptAndMigrateOldPinKeyEncryptedMasterKey( - userId: UserId, - pin: string, - email: string, - kdfConfig: KdfConfig, - requireMasterPasswordOnClientRestart: boolean, - oldPinKeyEncryptedMasterKey: EncString, - ): Promise { - this.validateUserId(userId, "Cannot decrypt and migrate oldPinKeyEncryptedMasterKey."); - - const masterKey = await this.decryptMasterKeyWithPin( - userId, - pin, - email, - kdfConfig, - oldPinKeyEncryptedMasterKey, - ); - - const encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ userId: userId }); - - const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( - masterKey, - userId, - encUserKey ? new EncString(encUserKey) : undefined, - ); - - const pinKeyEncryptedUserKey = await this.createPinKeyEncryptedUserKey(pin, userKey, userId); - await this.storePinKeyEncryptedUserKey( - pinKeyEncryptedUserKey, - requireMasterPasswordOnClientRestart, - userId, - ); - - const userKeyEncryptedPin = await this.createUserKeyEncryptedPin(pin, userKey); - await this.setUserKeyEncryptedPin(userKeyEncryptedPin, userId); - - await this.clearOldPinKeyEncryptedMasterKey(userId); - - return userKey; - } - - // Only for migration purposes - private async decryptMasterKeyWithPin( - userId: UserId, - pin: string, - salt: string, - kdfConfig: KdfConfig, - oldPinKeyEncryptedMasterKey?: EncString, - ): Promise { - this.validateUserId(userId, "Cannot decrypt master key with PIN."); - - if (!oldPinKeyEncryptedMasterKey) { - const oldPinKeyEncryptedMasterKeyString = await this.getOldPinKeyEncryptedMasterKey(userId); - - if (oldPinKeyEncryptedMasterKeyString == null) { - throw new Error("No oldPinKeyEncrytedMasterKey found."); - } - - oldPinKeyEncryptedMasterKey = new EncString(oldPinKeyEncryptedMasterKeyString); - } - - const pinKey = await this.makePinKey(pin, salt, kdfConfig); - const masterKey = await this.encryptService.decryptToBytes(oldPinKeyEncryptedMasterKey, pinKey); - - return new SymmetricCryptoKey(masterKey) as MasterKey; - } - - /** - * Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral) and `oldPinKeyEncryptedMasterKey` + * Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral) * (if one exists) based on the user's PinLockType. * - * @remarks The `oldPinKeyEncryptedMasterKey` (formerly `pinProtected`) is only used for migration and - * will be null for all migrated accounts. * @throws If PinLockType is 'DISABLED' or if userId is not provided */ private async getPinKeyEncryptedKeys( pinLockType: PinLockType, userId: UserId, - ): Promise<{ pinKeyEncryptedUserKey: EncString; oldPinKeyEncryptedMasterKey?: EncString }> { + ): Promise { this.validateUserId(userId, "Cannot get PinKey encrypted keys."); switch (pinLockType) { case "PERSISTENT": { - const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyPersistent(userId); - const oldPinKeyEncryptedMasterKey = await this.getOldPinKeyEncryptedMasterKey(userId); - - return { - pinKeyEncryptedUserKey, - oldPinKeyEncryptedMasterKey: oldPinKeyEncryptedMasterKey - ? new EncString(oldPinKeyEncryptedMasterKey) - : undefined, - }; + return await this.getPinKeyEncryptedUserKeyPersistent(userId); } case "EPHEMERAL": { - const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyEphemeral(userId); - - return { - pinKeyEncryptedUserKey, - oldPinKeyEncryptedMasterKey: undefined, // Going forward, we only migrate non-ephemeral version - }; + return await this.getPinKeyEncryptedUserKeyEphemeral(userId); } case "DISABLED": throw new Error("Pin is disabled"); diff --git a/libs/auth/src/common/services/pin/pin.service.spec.ts b/libs/auth/src/common/services/pin/pin.service.spec.ts index 794d08b63b2..ebdc36219ef 100644 --- a/libs/auth/src/common/services/pin/pin.service.spec.ts +++ b/libs/auth/src/common/services/pin/pin.service.spec.ts @@ -1,11 +1,9 @@ import { mock } from "jest-mock-extended"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -15,14 +13,13 @@ import { mockAccountServiceWith, } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; -import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key"; +import { PinKey, UserKey } from "@bitwarden/common/types/key"; import { DEFAULT_KDF_CONFIG, KdfConfigService } from "@bitwarden/key-management"; import { PinService, PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL, - OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, USER_KEY_ENCRYPTED_PIN, PinLockType, } from "./pin.service.implementation"; @@ -31,7 +28,6 @@ describe("PinService", () => { let sut: PinService; let accountService: FakeAccountService; - let masterPasswordService: FakeMasterPasswordService; let stateProvider: FakeStateProvider; const cryptoFunctionService = mock(); @@ -39,11 +35,9 @@ describe("PinService", () => { const kdfConfigService = mock(); const keyGenerationService = mock(); const logService = mock(); - const stateService = mock(); const mockUserId = Utils.newGuid() as UserId; const mockUserKey = new SymmetricCryptoKey(randomBytes(64)) as UserKey; - const mockMasterKey = new SymmetricCryptoKey(randomBytes(32)) as MasterKey; const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey; const mockUserEmail = "user@example.com"; const mockPin = "1234"; @@ -57,15 +51,10 @@ describe("PinService", () => { "2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw=", ); - const oldPinKeyEncryptedMasterKeyPostMigration: any = null; - const oldPinKeyEncryptedMasterKeyPreMigrationPersistent = - "2.fb5kOEZvh9zPABbP8WRmSQ==|Yi6ZAJY+UtqCKMUSqp1ahY9Kf8QuneKXs6BMkpNsakLVOzTYkHHlilyGABMF7GzUO8QHyZi7V/Ovjjg+Naf3Sm8qNhxtDhibITv4k8rDnM0=|TFkq3h2VNTT1z5BFbebm37WYuxyEHXuRo0DZJI7TQnw="; - beforeEach(() => { jest.clearAllMocks(); accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail }); - masterPasswordService = new FakeMasterPasswordService(); stateProvider = new FakeStateProvider(accountService); sut = new PinService( @@ -75,9 +64,7 @@ describe("PinService", () => { kdfConfigService, keyGenerationService, logService, - masterPasswordService, stateProvider, - stateService, ); }); @@ -111,12 +98,6 @@ describe("PinService", () => { await expect(sut.clearUserKeyEncryptedPin(undefined)).rejects.toThrow( "User ID is required. Cannot clear userKeyEncryptedPin.", ); - await expect(sut.getOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow( - "User ID is required. Cannot get oldPinKeyEncryptedMasterKey.", - ); - await expect(sut.clearOldPinKeyEncryptedMasterKey(undefined)).rejects.toThrow( - "User ID is required. Cannot clear oldPinKeyEncryptedMasterKey.", - ); await expect( sut.createPinKeyEncryptedUserKey(mockPin, mockUserKey, undefined), ).rejects.toThrow("User ID is required. Cannot create pinKeyEncryptedUserKey."); @@ -288,31 +269,6 @@ describe("PinService", () => { }); }); - describe("oldPinKeyEncryptedMasterKey methods", () => { - describe("getOldPinKeyEncryptedMasterKey()", () => { - it("should get the oldPinKeyEncryptedMasterKey of the specified userId", async () => { - await sut.getOldPinKeyEncryptedMasterKey(mockUserId); - - expect(stateProvider.mock.getUserState$).toHaveBeenCalledWith( - OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, - mockUserId, - ); - }); - }); - - describe("clearOldPinKeyEncryptedMasterKey()", () => { - it("should clear the oldPinKeyEncryptedMasterKey of the specified userId", async () => { - await sut.clearOldPinKeyEncryptedMasterKey(mockUserId); - - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith( - OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, - null, - mockUserId, - ); - }); - }); - }); - describe("makePinKey()", () => { it("should make a PinKey", async () => { // Arrange @@ -346,26 +302,10 @@ describe("PinService", () => { expect(result).toBe("PERSISTENT"); }); - it("should return 'PERSISTENT' if an old oldPinKeyEncryptedMasterKey is found", async () => { - // Arrange - sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null); - sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null); - sut.getOldPinKeyEncryptedMasterKey = jest - .fn() - .mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent); - - // Act - const result = await sut.getPinLockType(mockUserId); - - // Assert - expect(result).toBe("PERSISTENT"); - }); - - it("should return 'EPHEMERAL' if neither a pinKeyEncryptedUserKey (persistent version) nor an old oldPinKeyEncryptedMasterKey are found, but a userKeyEncryptedPin is found", async () => { + it("should return 'EPHEMERAL' if a pinKeyEncryptedUserKey (persistent version) is not found but a userKeyEncryptedPin is found", async () => { // Arrange sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin); sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null); - sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null); // Act const result = await sut.getPinLockType(mockUserId); @@ -374,11 +314,10 @@ describe("PinService", () => { expect(result).toBe("EPHEMERAL"); }); - it("should return 'DISABLED' if ALL three of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version), oldPinKeyEncryptedMasterKey", async () => { + it("should return 'DISABLED' if both of these are NOT found: userKeyEncryptedPin, pinKeyEncryptedUserKey (persistent version)", async () => { // Arrange sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(null); sut.getPinKeyEncryptedUserKeyPersistent = jest.fn().mockResolvedValue(null); - sut.getOldPinKeyEncryptedMasterKey = jest.fn().mockResolvedValue(null); // Act const result = await sut.getPinLockType(mockUserId); @@ -476,46 +415,20 @@ describe("PinService", () => { }); describe("decryptUserKeyWithPin()", () => { - async function setupDecryptUserKeyWithPinMocks( - pinLockType: PinLockType, - migrationStatus: "PRE" | "POST" = "POST", - ) { + async function setupDecryptUserKeyWithPinMocks(pinLockType: PinLockType) { sut.getPinLockType = jest.fn().mockResolvedValue(pinLockType); - mockPinEncryptedKeyDataByPinLockType(pinLockType, migrationStatus); + mockPinEncryptedKeyDataByPinLockType(pinLockType); kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG); - if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") { - await mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn(); - } else { - mockDecryptUserKeyFn(); - } + mockDecryptUserKeyFn(); sut.getUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin); encryptService.decryptToUtf8.mockResolvedValue(mockPin); cryptoFunctionService.compareFast.calledWith(mockPin, "1234").mockResolvedValue(true); } - async function mockDecryptAndMigrateOldPinKeyEncryptedMasterKeyFn() { - sut.makePinKey = jest.fn().mockResolvedValue(mockPinKey); - encryptService.decryptToBytes.mockResolvedValue(mockMasterKey.key); - - stateService.getEncryptedCryptoSymmetricKey.mockResolvedValue(mockUserKey.keyB64); - masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey); - - sut.createPinKeyEncryptedUserKey = jest - .fn() - .mockResolvedValue(pinKeyEncryptedUserKeyPersistant); - - await sut.storePinKeyEncryptedUserKey(pinKeyEncryptedUserKeyPersistant, false, mockUserId); - - sut.createUserKeyEncryptedPin = jest.fn().mockResolvedValue(mockUserKeyEncryptedPin); - await sut.setUserKeyEncryptedPin(mockUserKeyEncryptedPin, mockUserId); - - await sut.clearOldPinKeyEncryptedMasterKey(mockUserId); - } - function mockDecryptUserKeyFn() { sut.getPinKeyEncryptedUserKeyPersistent = jest .fn() @@ -524,26 +437,12 @@ describe("PinService", () => { encryptService.decryptToBytes.mockResolvedValue(mockUserKey.key); } - function mockPinEncryptedKeyDataByPinLockType( - pinLockType: PinLockType, - migrationStatus: "PRE" | "POST" = "POST", - ) { + function mockPinEncryptedKeyDataByPinLockType(pinLockType: PinLockType) { switch (pinLockType) { case "PERSISTENT": sut.getPinKeyEncryptedUserKeyPersistent = jest .fn() .mockResolvedValue(pinKeyEncryptedUserKeyPersistant); - - if (migrationStatus === "PRE") { - sut.getOldPinKeyEncryptedMasterKey = jest - .fn() - .mockResolvedValue(oldPinKeyEncryptedMasterKeyPreMigrationPersistent); - } else { - sut.getOldPinKeyEncryptedMasterKey = jest - .fn() - .mockResolvedValue(oldPinKeyEncryptedMasterKeyPostMigration); // null - } - break; case "EPHEMERAL": sut.getPinKeyEncryptedUserKeyEphemeral = jest @@ -557,49 +456,16 @@ describe("PinService", () => { } } - const testCases: { pinLockType: PinLockType; migrationStatus: "PRE" | "POST" }[] = [ - { pinLockType: "PERSISTENT", migrationStatus: "PRE" }, - { pinLockType: "PERSISTENT", migrationStatus: "POST" }, - { pinLockType: "EPHEMERAL", migrationStatus: "POST" }, + const testCases: { pinLockType: PinLockType }[] = [ + { pinLockType: "PERSISTENT" }, + { pinLockType: "EPHEMERAL" }, ]; - testCases.forEach(({ pinLockType, migrationStatus }) => { - describe(`given a ${pinLockType} PIN (${migrationStatus} migration)`, () => { - if (pinLockType === "PERSISTENT" && migrationStatus === "PRE") { - it("should clear the oldPinKeyEncryptedMasterKey from state", async () => { - // Arrange - await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus); - - // Act - await sut.decryptUserKeyWithPin(mockPin, mockUserId); - - // Assert - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith( - OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, - null, - mockUserId, - ); - }); - - it("should set the new pinKeyEncrypterUserKeyPersistent to state", async () => { - // Arrange - await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus); - - // Act - await sut.decryptUserKeyWithPin(mockPin, mockUserId); - - // Assert - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith( - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, - pinKeyEncryptedUserKeyPersistant.encryptedString, - mockUserId, - ); - }); - } - + testCases.forEach(({ pinLockType }) => { + describe(`given a ${pinLockType} PIN)`, () => { it(`should successfully decrypt and return user key when using a valid PIN`, async () => { // Arrange - await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus); + await setupDecryptUserKeyWithPinMocks(pinLockType); // Act const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId); @@ -610,7 +476,7 @@ describe("PinService", () => { it(`should return null when PIN is incorrect and user key cannot be decrypted`, async () => { // Arrange - await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus); + await setupDecryptUserKeyWithPinMocks(pinLockType); sut.decryptUserKeyWithPin = jest.fn().mockResolvedValue(null); // Act @@ -623,7 +489,7 @@ describe("PinService", () => { // not sure if this is a realistic scenario but going to test it anyway it(`should return null when PIN doesn't match after successful user key decryption`, async () => { // Arrange - await setupDecryptUserKeyWithPinMocks(pinLockType, migrationStatus); + await setupDecryptUserKeyWithPinMocks(pinLockType); encryptService.decryptToUtf8.mockResolvedValue("9999"); // non matching PIN // Act diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 72987b13827..9ed01cf0c83 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -163,19 +163,6 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr throw new Error("No master key found."); } - // Try one more way to get the user key if it still wasn't found. - if (userKey == null) { - const deprecatedKey = await this.stateService.getEncryptedCryptoSymmetricKey({ - userId: userId, - }); - - if (deprecatedKey == null) { - throw new Error("No encrypted user key found."); - } - - userKey = new EncString(deprecatedKey); - } - let decUserKey: Uint8Array; if (userKey.encryptionType === EncryptionType.AesCbc256_B64) { diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts index 1762b9156db..d71b8972727 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts @@ -147,7 +147,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { await this.masterPasswordService.clearMasterKey(lockingUserId); await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId }); - await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId }); await this.cipherService.clearCache(lockingUserId); diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 3b0ce07623b..e4dbe76d7e4 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -50,14 +50,6 @@ export abstract class StateService { value: boolean, options?: StorageOptions, ) => Promise; - /** - * @deprecated For migration purposes only, use getUserKeyMasterKey instead - */ - getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; - /** - * @deprecated For migration purposes only, use setUserKeyAuto instead - */ - setCryptoMasterKeyAuto: (value: string | null, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 9873e5c8574..b9d10f47e97 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -48,8 +48,6 @@ export class EncryptionPair { export class AccountKeys { publicKey?: Uint8Array; - /** @deprecated July 2023, left for migration purposes*/ - cryptoMasterKeyAuto?: string; /** @deprecated July 2023, left for migration purposes*/ cryptoSymmetricKey?: EncryptionPair = new EncryptionPair< string, diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index a78a9b37a8c..284c8a7f2dc 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -222,45 +222,6 @@ export class StateService< await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options); } - /** - * @deprecated Use UserKeyAuto instead - */ - async setCryptoMasterKeyAuto(value: string | null, options?: StorageOptions): Promise { - options = this.reconcileOptions( - this.reconcileOptions(options, { keySuffix: "auto" }), - await this.defaultSecureStorageOptions(), - ); - if (options?.userId == null) { - return; - } - await this.saveSecureStorageKey(partialKeys.autoKey, value, options); - } - - /** - * @deprecated I don't see where this is even used - */ - async getCryptoMasterKeyB64(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return null; - } - return await this.secureStorageService.get( - `${options?.userId}${partialKeys.masterKey}`, - options, - ); - } - - /** - * @deprecated I don't see where this is even used - */ - async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - await this.saveSecureStorageKey(partialKeys.masterKey, value, options); - } - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { @@ -619,8 +580,6 @@ export class StateService< await this.setUserKeyAutoUnlock(null, { userId: userId }); await this.setUserKeyBiometric(null, { userId: userId }); - await this.setCryptoMasterKeyAuto(null, { userId: userId }); - await this.setCryptoMasterKeyB64(null, { userId: userId }); } protected async removeAccountFromMemory(userId: string = null): Promise { diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 659dd1bbb29..4b257fbbebd 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -390,14 +390,6 @@ export abstract class KeyService { publicKey: string; privateKey: EncString; }>; - /** - * Previously, the master key was used for any additional key like the biometrics or pin key. - * We have switched to using the user key for these purposes. This method is for clearing the state - * of the older keys on logout or post migration. - * @param keySuffix The desired type of key to clear - * @param userId The desired user - */ - abstract clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string): Promise; /** * Retrieves all the keys needed for decrypting Ciphers diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 2eab9de7487..a0afc160794 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -252,14 +252,6 @@ describe("keyService", () => { userId: mockUserId, }); }); - - it("clears the old deprecated Auto key whenever a User Key is set", async () => { - await keyService.setUserKey(mockUserKey, mockUserId); - - expect(stateService.setCryptoMasterKeyAuto).toHaveBeenCalledWith(null, { - userId: mockUserId, - }); - }); }); it("throws if key is null", async () => { diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index b18405a4200..870513f3913 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -254,16 +254,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId); } if (keySuffix === KeySuffixOptions.Pin && userId != null) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId); } } @@ -565,7 +559,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId); await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId); await this.pinService.clearUserKeyEncryptedPin(userId); - await this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId); } async makeSendKey(keyMaterial: CsprngArray): Promise { @@ -726,7 +719,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { } else { await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); } - await this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId); const storePin = await this.shouldStoreKey(KeySuffixOptions.Pin, userId); if (storePin) { @@ -749,9 +741,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { noPreExistingPersistentKey, userId, ); - // We can't always clear deprecated keys because the pin is only - // migrated once used to unlock - await this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId); } else { await this.pinService.clearPinKeyEncryptedUserKeyPersistent(userId); await this.pinService.clearPinKeyEncryptedUserKeyEphemeral(userId); @@ -835,19 +824,6 @@ export class DefaultKeyService implements KeyServiceAbstraction { return [new SymmetricCryptoKey(newSymKey) as T, protectedSymKey]; } - // --LEGACY METHODS-- - // We previously used the master key for additional keys, but now we use the user key. - // These methods support migrating the old keys to the new ones. - // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3475) - - async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: UserId) { - if (keySuffix === KeySuffixOptions.Auto) { - await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); - } else if (keySuffix === KeySuffixOptions.Pin && userId != null) { - await this.pinService.clearOldPinKeyEncryptedMasterKey(userId); - } - } - userKey$(userId: UserId): Observable { return this.stateProvider.getUser(userId, USER_KEY).state$; } From 15738f16aee4d23e1e32d3c4634340b3bb8be46c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 31 Mar 2025 16:59:01 +0200 Subject: [PATCH 052/228] [PM-18038] Fix safari using outdated biometrics protocol (#13287) * Fix safari using outdated biometrics protocol * Remove logging * Remove log * Move canEnableBiometricUnlock to biometric service * Fix build * Add tests * Fix type error * Attempt to fix build * Fix build * Fix test failure --- .../settings/account-security.component.ts | 13 ++-- .../browser/src/background/main.background.ts | 17 +++--- .../background/nativeMessaging.background.ts | 17 ++++-- .../src/background/runtime.background.ts | 4 ++ ...kground-browser-biometrics.service.spec.ts | 61 +++++++++++++++++++ .../background-browser-biometrics.service.ts | 12 ++++ .../foreground-browser-biometrics.spec.ts | 60 ++++++++++++++++++ .../foreground-browser-biometrics.ts | 20 ++++++ .../src/popup/services/services.module.ts | 6 +- .../safari/SafariWebExtensionHandler.swift | 8 +-- .../key-management/cli-biometrics-service.ts | 3 + .../app/accounts/settings.component.spec.ts | 1 + .../src/app/accounts/settings.component.ts | 16 +---- .../biometrics/main-biometrics.service.ts | 4 ++ .../renderer-biometrics.service.spec.ts | 44 +++++++++++++ .../biometrics/renderer-biometrics.service.ts | 9 +++ .../key-management/web-biometric.service.ts | 4 ++ .../src/biometrics/biometric.service.ts | 1 + .../src/biometrics/biometrics-commands.ts | 3 + 19 files changed, 264 insertions(+), 39 deletions(-) create mode 100644 apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts create mode 100644 apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts create mode 100644 apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts 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 75b59b8efdc..66e5b0bb214 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -233,11 +233,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { .pipe( switchMap(async () => { const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id); - const biometricSettingAvailable = - !(await BrowserApi.permissionsGranted(["nativeMessaging"])) || - (status !== BiometricsStatus.DesktopDisconnected && - status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) || - (await this.vaultTimeoutSettingsService.isBiometricLockSet()); + const biometricSettingAvailable = await this.biometricsService.canEnableBiometricUnlock(); if (!biometricSettingAvailable) { this.form.controls.biometric.disable({ emitEvent: false }); } else { @@ -256,6 +252,13 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { "biometricsStatusHelptextNotEnabledInDesktop", activeAccount.email, ); + } else if ( + status === BiometricsStatus.HardwareUnavailable && + !biometricSettingAvailable + ) { + this.biometricUnavailabilityReason = this.i18nService.t( + "biometricsStatusHelptextHardwareUnavailable", + ); } else { this.biometricUnavailabilityReason = ""; } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index eec07b5b1ed..5cc964c2c2d 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -672,14 +672,6 @@ export default class MainBackground { this.kdfConfigService, ); - this.biometricsService = new BackgroundBrowserBiometricsService( - runtimeNativeMessagingBackground, - this.logService, - this.keyService, - this.biometricStateService, - this.messagingService, - ); - this.appIdService = new AppIdService(this.storageService, this.logService); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); @@ -699,6 +691,15 @@ export default class MainBackground { VaultTimeoutStringType.OnRestart, // default vault timeout ); + this.biometricsService = new BackgroundBrowserBiometricsService( + runtimeNativeMessagingBackground, + this.logService, + this.keyService, + this.biometricStateService, + this.messagingService, + this.vaultTimeoutSettingsService, + ); + this.apiService = new ApiService( this.tokenService, this.platformUtilsService, diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 8100ff3cffa..69521228bc5 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -120,9 +120,15 @@ export class NativeMessagingBackground { this.connecting = true; const connectedCallback = () => { - this.logService.info( - "[Native Messaging IPC] Connection to Bitwarden Desktop app established!", - ); + if (!this.platformUtilsService.isSafari()) { + this.logService.info( + "[Native Messaging IPC] Connection to Bitwarden Desktop app established!", + ); + } else { + this.logService.info( + "[Native Messaging IPC] Connection to Safari swift module established!", + ); + } this.connected = true; this.connecting = false; resolve(); @@ -131,6 +137,7 @@ export class NativeMessagingBackground { // Safari has a bundled native component which is always available, no need to // check if the desktop app is running. if (this.platformUtilsService.isSafari()) { + this.isConnectedToOutdatedDesktopClient = false; connectedCallback(); } @@ -428,7 +435,9 @@ export class NativeMessagingBackground { } if (this.callbacks.has(messageId)) { - this.callbacks.get(messageId)!.resolver(message); + const callback = this.callbacks!.get(messageId)!; + this.callbacks.delete(messageId); + callback.resolver(message); } else { this.logService.info("[Native Messaging IPC] Received message without a callback", message); } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 7db72f38139..d31ccdd97b0 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -78,6 +78,7 @@ export default class RuntimeBackground { BiometricsCommands.GetBiometricsStatus, BiometricsCommands.UnlockWithBiometricsForUser, BiometricsCommands.GetBiometricsStatusForUser, + BiometricsCommands.CanEnableBiometricUnlock, "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", "getInlineMenuFieldQualificationFeatureFlag", "getUserPremiumStatus", @@ -201,6 +202,9 @@ export default class RuntimeBackground { case BiometricsCommands.GetBiometricsStatusForUser: { return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId); } + case BiometricsCommands.CanEnableBiometricUnlock: { + return await this.main.biometricsService.canEnableBiometricUnlock(); + } case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": { return await this.configService.getFeatureFlag( FeatureFlag.UseTreeWalkerApiForPageDetailsCollection, diff --git a/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts new file mode 100644 index 00000000000..4017953ee28 --- /dev/null +++ b/apps/browser/src/key-management/biometrics/background-browser-biometrics.service.spec.ts @@ -0,0 +1,61 @@ +import { mock } from "jest-mock-extended"; + +import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; + +import { NativeMessagingBackground } from "../../background/nativeMessaging.background"; + +import { BackgroundBrowserBiometricsService } from "./background-browser-biometrics.service"; + +describe("background browser biometrics service tests", function () { + let service: BackgroundBrowserBiometricsService; + + const nativeMessagingBackground = mock(); + const logService = mock(); + const keyService = mock(); + const biometricStateService = mock(); + const messagingService = mock(); + const vaultTimeoutSettingsService = mock(); + + beforeEach(() => { + jest.resetAllMocks(); + service = new BackgroundBrowserBiometricsService( + () => nativeMessagingBackground, + logService, + keyService, + biometricStateService, + messagingService, + vaultTimeoutSettingsService, + ); + }); + + describe("canEnableBiometricUnlock", () => { + const table: [BiometricsStatus, boolean, boolean][] = [ + // status, already enabled, expected + + // if the setting is not already on, it should only be possible to enable it if biometrics are available + [BiometricsStatus.Available, false, true], + [BiometricsStatus.HardwareUnavailable, false, false], + [BiometricsStatus.NotEnabledInConnectedDesktopApp, false, false], + [BiometricsStatus.DesktopDisconnected, false, false], + + // if the setting is already on, it should always be possible to disable it + [BiometricsStatus.Available, true, true], + [BiometricsStatus.HardwareUnavailable, true, true], + [BiometricsStatus.NotEnabledInConnectedDesktopApp, true, true], + [BiometricsStatus.DesktopDisconnected, true, true], + ]; + test.each(table)( + "status: %s, already enabled: %s, expected: %s", + async (status, alreadyEnabled, expected) => { + service.getBiometricsStatus = jest.fn().mockResolvedValue(status); + vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(alreadyEnabled); + const result = await service.canEnableBiometricUnlock(); + + expect(result).toBe(expected); + }, + ); + }); +}); 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 3031134dc34..a8a89d45274 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 @@ -1,5 +1,6 @@ import { Injectable } from "@angular/core"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -25,6 +26,7 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { private keyService: KeyService, private biometricStateService: BiometricStateService, private messagingService: MessagingService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, ) { super(); } @@ -169,4 +171,14 @@ export class BackgroundBrowserBiometricsService extends BiometricsService { } async setShouldAutopromptNow(value: boolean): Promise {} + async canEnableBiometricUnlock(): Promise { + const status = await this.getBiometricsStatus(); + const isBiometricsAlreadyEnabled = await this.vaultTimeoutSettingsService.isBiometricLockSet(); + const statusAllowsBiometric = + status !== BiometricsStatus.DesktopDisconnected && + status !== BiometricsStatus.NotEnabledInConnectedDesktopApp && + status !== BiometricsStatus.HardwareUnavailable; + + return statusAllowsBiometric || isBiometricsAlreadyEnabled; + } } diff --git a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts new file mode 100644 index 00000000000..672eec8c1fc --- /dev/null +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.spec.ts @@ -0,0 +1,60 @@ +import { mock } from "jest-mock-extended"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { BrowserApi } from "../../platform/browser/browser-api"; + +import { ForegroundBrowserBiometricsService } from "./foreground-browser-biometrics"; + +jest.mock("../../platform/browser/browser-api", () => ({ + BrowserApi: { + sendMessageWithResponse: jest.fn(), + permissionsGranted: jest.fn(), + }, +})); + +describe("foreground browser biometrics service tests", function () { + const platformUtilsService = mock(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe("canEnableBiometricUnlock", () => { + const table: [boolean, boolean, boolean, boolean][] = [ + // canEnableBiometricUnlock from background, native permission granted, isSafari, expected + + // needs permission prompt; always allowed + [true, false, false, true], + [false, false, false, true], + + // is safari; depends on the status that the background service reports + [false, false, true, false], + [true, false, true, true], + + // native permissions granted; depends on the status that the background service reports + [false, true, false, false], + [true, true, false, true], + + // should never happen since safari does not use the permissions + [false, true, true, false], + [true, true, true, true], + ]; + test.each(table)( + "canEnableBiometric: %s, native permission granted: %s, isSafari: %s, expected: %s", + async (canEnableBiometricUnlockBackground, granted, isSafari, expected) => { + const service = new ForegroundBrowserBiometricsService(platformUtilsService); + + (BrowserApi.permissionsGranted as jest.Mock).mockResolvedValue(granted); + (BrowserApi.sendMessageWithResponse as jest.Mock).mockResolvedValue({ + result: canEnableBiometricUnlockBackground, + }); + platformUtilsService.isSafari.mockReturnValue(isSafari); + + const result = await service.canEnableBiometricUnlock(); + + expect(result).toBe(expected); + }, + ); + }); +}); diff --git a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts index d248a630cc6..b6e84fee31a 100644 --- a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts @@ -1,3 +1,4 @@ +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; @@ -8,6 +9,10 @@ import { BrowserApi } from "../../platform/browser/browser-api"; export class ForegroundBrowserBiometricsService extends BiometricsService { shouldAutopromptNow = true; + constructor(private platformUtilsService: PlatformUtilsService) { + super(); + } + async authenticateWithBiometrics(): Promise { const response = await BrowserApi.sendMessageWithResponse<{ result: boolean; @@ -52,4 +57,19 @@ export class ForegroundBrowserBiometricsService extends BiometricsService { async setShouldAutopromptNow(value: boolean): Promise { this.shouldAutopromptNow = value; } + + async canEnableBiometricUnlock(): Promise { + const needsPermissionPrompt = + !(await BrowserApi.permissionsGranted(["nativeMessaging"])) && + !this.platformUtilsService.isSafari(); + return ( + needsPermissionPrompt || + ( + await BrowserApi.sendMessageWithResponse<{ + result: boolean; + error: string; + }>(BiometricsCommands.CanEnableBiometricUnlock) + ).result + ); + } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 42a05f14007..26e84ea964c 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -316,10 +316,8 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: BiometricsService, - useFactory: () => { - return new ForegroundBrowserBiometricsService(); - }, - deps: [], + useClass: ForegroundBrowserBiometricsService, + deps: [PlatformUtilsService], }), safeProvider({ provide: SyncService, diff --git a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift index d4ce360c32a..54e91611325 100644 --- a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift +++ b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift @@ -152,7 +152,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": false, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "messageId": messageId, @@ -177,7 +177,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": false, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "messageId": messageId, @@ -209,7 +209,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": true, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "userKeyB64": result!.replacingOccurrences(of: "\"", with: ""), @@ -220,7 +220,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { response.userInfo = [ SFExtensionMessageKey: [ "message": [ - "command": "biometricUnlock", + "command": "unlockWithBiometricsForUser", "response": true, "timestamp": Int64(NSDate().timeIntervalSince1970 * 1000), "messageId": messageId, diff --git a/apps/cli/src/key-management/cli-biometrics-service.ts b/apps/cli/src/key-management/cli-biometrics-service.ts index bda8fe82895..b4f802eb053 100644 --- a/apps/cli/src/key-management/cli-biometrics-service.ts +++ b/apps/cli/src/key-management/cli-biometrics-service.ts @@ -24,4 +24,7 @@ export class CliBiometricsService extends BiometricsService { } async setShouldAutopromptNow(value: boolean): Promise {} + async canEnableBiometricUnlock(): Promise { + return false; + } } diff --git a/apps/desktop/src/app/accounts/settings.component.spec.ts b/apps/desktop/src/app/accounts/settings.component.spec.ts index d29147c1823..b05d90b7e1c 100644 --- a/apps/desktop/src/app/accounts/settings.component.spec.ts +++ b/apps/desktop/src/app/accounts/settings.component.spec.ts @@ -248,6 +248,7 @@ describe("SettingsComponent", () => { describe("biometrics enabled", () => { beforeEach(() => { desktopBiometricsService.getBiometricsStatus.mockResolvedValue(BiometricsStatus.Available); + desktopBiometricsService.canEnableBiometricUnlock.mockResolvedValue(true); vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true); }); diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 20b6d509f4d..a592e542d58 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -388,24 +388,12 @@ export class SettingsComponent implements OnInit, OnDestroy { } }); - this.supportsBiometric = this.shouldAllowBiometricSetup( - await this.biometricsService.getBiometricsStatus(), - ); + this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock(); this.timerId = setInterval(async () => { - this.supportsBiometric = this.shouldAllowBiometricSetup( - await this.biometricsService.getBiometricsStatus(), - ); + this.supportsBiometric = await this.biometricsService.canEnableBiometricUnlock(); }, 1000); } - private shouldAllowBiometricSetup(biometricStatus: BiometricsStatus): boolean { - return [ - BiometricsStatus.Available, - BiometricsStatus.AutoSetupNeeded, - BiometricsStatus.ManualSetupNeeded, - ].includes(biometricStatus); - } - async saveVaultTimeout(newValue: VaultTimeout) { if (newValue === VaultTimeoutStringType.Never) { const confirmed = await this.dialogService.openSimpleDialog({ diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index dd2e15a2fe8..cf80fa5f7f3 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -163,4 +163,8 @@ export class MainBiometricsService extends DesktopBiometricsService { async getShouldAutopromptNow(): Promise { return this.shouldAutoPrompt; } + + async canEnableBiometricUnlock(): Promise { + return true; + } } diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts new file mode 100644 index 00000000000..7a3f00c7c44 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.spec.ts @@ -0,0 +1,44 @@ +import { BiometricsStatus } from "@bitwarden/key-management"; + +import { RendererBiometricsService } from "./renderer-biometrics.service"; + +describe("renderer biometrics service tests", function () { + beforeEach(() => { + (global as any).ipc = { + keyManagement: { + biometric: { + authenticateWithBiometrics: jest.fn(), + getBiometricsStatus: jest.fn(), + unlockWithBiometricsForUser: jest.fn(), + getBiometricsStatusForUser: jest.fn(), + deleteBiometricUnlockKeyForUser: jest.fn(), + setupBiometrics: jest.fn(), + setClientKeyHalfForUser: jest.fn(), + getShouldAutoprompt: jest.fn(), + setShouldAutoprompt: jest.fn(), + }, + }, + }; + }); + + describe("canEnableBiometricUnlock", () => { + const table: [BiometricsStatus, boolean][] = [ + [BiometricsStatus.Available, true], + [BiometricsStatus.AutoSetupNeeded, true], + [BiometricsStatus.ManualSetupNeeded, true], + + [BiometricsStatus.UnlockNeeded, false], + [BiometricsStatus.HardwareUnavailable, false], + [BiometricsStatus.PlatformUnsupported, false], + [BiometricsStatus.NotEnabledLocally, false], + ]; + test.each(table)("canEnableBiometricUnlock(%s) === %s", async (status, expected) => { + const service = new RendererBiometricsService(); + (global as any).ipc.keyManagement.biometric.getBiometricsStatus.mockResolvedValue(status); + + const result = await service.canEnableBiometricUnlock(); + + expect(result).toBe(expected); + }); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index 2a0b1282778..db17ee480cb 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -51,4 +51,13 @@ export class RendererBiometricsService extends DesktopBiometricsService { async setShouldAutopromptNow(value: boolean): Promise { return await ipc.keyManagement.biometric.setShouldAutoprompt(value); } + + async canEnableBiometricUnlock(): Promise { + const biometricStatus = await this.getBiometricsStatus(); + return [ + BiometricsStatus.Available, + BiometricsStatus.AutoSetupNeeded, + BiometricsStatus.ManualSetupNeeded, + ].includes(biometricStatus); + } } diff --git a/apps/web/src/app/key-management/web-biometric.service.ts b/apps/web/src/app/key-management/web-biometric.service.ts index 0c58c0da759..64fa0d243cd 100644 --- a/apps/web/src/app/key-management/web-biometric.service.ts +++ b/apps/web/src/app/key-management/web-biometric.service.ts @@ -24,4 +24,8 @@ export class WebBiometricsService extends BiometricsService { } async setShouldAutopromptNow(value: boolean): Promise {} + + async canEnableBiometricUnlock(): Promise { + return false; + } } diff --git a/libs/key-management/src/biometrics/biometric.service.ts b/libs/key-management/src/biometrics/biometric.service.ts index 3543185b632..119d89e0c9f 100644 --- a/libs/key-management/src/biometrics/biometric.service.ts +++ b/libs/key-management/src/biometrics/biometric.service.ts @@ -39,4 +39,5 @@ export abstract class BiometricsService { abstract getShouldAutopromptNow(): Promise; abstract setShouldAutopromptNow(value: boolean): Promise; + abstract canEnableBiometricUnlock(): Promise; } diff --git a/libs/key-management/src/biometrics/biometrics-commands.ts b/libs/key-management/src/biometrics/biometrics-commands.ts index 2309e8d30bc..81f0ea747e4 100644 --- a/libs/key-management/src/biometrics/biometrics-commands.ts +++ b/libs/key-management/src/biometrics/biometrics-commands.ts @@ -8,6 +8,9 @@ export enum BiometricsCommands { /** Get biometric status for a specific user account. This includes both information about availability of cryptographic material (is the user configured for biometric unlock? is a masterpassword unlock needed? But also information about the biometric system's availability in a single status) */ GetBiometricsStatusForUser = "getBiometricsStatusForUser", + /** Checks whether the biometric unlock can be enabled. */ + CanEnableBiometricUnlock = "canEnableBiometricUnlock", + // legacy Unlock = "biometricUnlock", IsAvailable = "biometricUnlockAvailable", From 7992e0247e8de7121f14b8efc7a2ca0aebf65048 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Mon, 31 Mar 2025 17:06:27 +0200 Subject: [PATCH 053/228] Fix wrong file extension being set when exporting with attachments (#14067) Co-authored-by: Daniel James Smith --- .../src/services/individual-vault-export.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index 489a28b4c79..1fcdb84d375 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -126,7 +126,7 @@ export class IndividualVaultExportService return { type: "application/zip", data: blobData, - fileName: ExportHelper.getFileName("", "json"), + fileName: ExportHelper.getFileName("", "zip"), } as ExportedVaultAsBlob; } From 2381d6b3cdb729c01d61be3f4229f5b06cadc89b Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 31 Mar 2025 11:07:02 -0400 Subject: [PATCH 054/228] migrate provider clients component (#14030) --- .../providers/clients/clients.component.html | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html index e6b5ae122f3..668d59b8830 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.html @@ -21,7 +21,7 @@ @@ -31,35 +31,31 @@

{{ "noClientsInList" | i18n }}

- + {{ "name" | i18n }} {{ "numberOfUsers" | i18n }} {{ "billingPlan" | i18n }} - - + {{ row.organizationName }} - + {{ row.userCount }} / {{ row.seats }} - + {{ row.plan }} - - -