From 56a122980351e2baf49b55002fd020b98c163a32 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Wed, 11 Oct 2023 16:09:01 +0200 Subject: [PATCH 01/85] [PM-4016] Address feedback on [PM-2014] (#6532) * [PM-4016] feat: use dialog `loading` attribute * [PM-4016] chore: move constant to service * [PM-4016] chore: simplify paddings * [PM-4016] chore: rename to `AuthSettingsModule` * [PM-4016] fix: move request creation to service * [PM-4016] feat: simplify module structure Remove core.module and use `@Injectable({ providedIn: "root" })` instead. --- apps/web/src/app/auth/auth.module.ts | 7 +++---- apps/web/src/app/auth/core/core.module.ts | 15 --------------- apps/web/src/app/auth/core/index.ts | 1 - .../webauthn-login/webauthn-login-api.service.ts | 16 +++++----------- .../webauthn-login.service.spec.ts | 6 +++++- .../webauthn-login/webauthn-login.service.ts | 12 +++++++++--- .../web/src/app/auth/settings/settings.module.ts | 4 ++-- .../create-credential-dialog.component.html | 2 +- .../create-credential-dialog.component.ts | 1 + .../delete-credential-dialog.component.html | 2 +- .../delete-credential-dialog.component.ts | 1 + .../webauthn-login-settings.component.html | 4 ++-- .../webauthn-login-settings.component.ts | 2 +- 13 files changed, 31 insertions(+), 42 deletions(-) delete mode 100644 apps/web/src/app/auth/core/core.module.ts diff --git a/apps/web/src/app/auth/auth.module.ts b/apps/web/src/app/auth/auth.module.ts index 49be17aa264..056b9f161f9 100644 --- a/apps/web/src/app/auth/auth.module.ts +++ b/apps/web/src/app/auth/auth.module.ts @@ -1,12 +1,11 @@ import { NgModule } from "@angular/core"; -import { CoreAuthModule } from "./core"; -import { SettingsModule } from "./settings/settings.module"; +import { AuthSettingsModule } from "./settings/settings.module"; @NgModule({ - imports: [CoreAuthModule, SettingsModule], + imports: [AuthSettingsModule], declarations: [], providers: [], - exports: [SettingsModule], + exports: [AuthSettingsModule], }) export class AuthModule {} diff --git a/apps/web/src/app/auth/core/core.module.ts b/apps/web/src/app/auth/core/core.module.ts deleted file mode 100644 index e196b1c3d76..00000000000 --- a/apps/web/src/app/auth/core/core.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NgModule, Optional, SkipSelf } from "@angular/core"; - -import { WebauthnLoginApiService } from "./services/webauthn-login/webauthn-login-api.service"; -import { WebauthnLoginService } from "./services/webauthn-login/webauthn-login.service"; - -@NgModule({ - providers: [WebauthnLoginService, WebauthnLoginApiService], -}) -export class CoreAuthModule { - constructor(@Optional() @SkipSelf() parentModule?: CoreAuthModule) { - if (parentModule) { - throw new Error("CoreAuthModule is already loaded. Import it in AuthModule only"); - } - } -} diff --git a/apps/web/src/app/auth/core/index.ts b/apps/web/src/app/auth/core/index.ts index 3d2d739adf9..b2221a94a89 100644 --- a/apps/web/src/app/auth/core/index.ts +++ b/apps/web/src/app/auth/core/index.ts @@ -1,2 +1 @@ export * from "./services"; -export * from "./core.module"; diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts index 33e1aea369b..6dc61563491 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts @@ -1,25 +1,20 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { Verification } from "@bitwarden/common/types/verification"; import { SaveCredentialRequest } from "./request/save-credential.request"; import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauthn-login-credential-create-options.response"; import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response"; -@Injectable() +@Injectable({ providedIn: "root" }) export class WebauthnLoginApiService { - constructor( - private apiService: ApiService, - private userVerificationService: UserVerificationService - ) {} + constructor(private apiService: ApiService) {} async getCredentialCreateOptions( - verification: Verification + request: SecretVerificationRequest ): Promise { - const request = await this.userVerificationService.buildRequest(verification); const response = await this.apiService.send("POST", "/webauthn/options", request, true, true); return new WebauthnLoginCredentialCreateOptionsResponse(response); } @@ -33,8 +28,7 @@ export class WebauthnLoginApiService { return this.apiService.send("GET", "/webauthn", null, true, true); } - async deleteCredential(credentialId: string, verification: Verification): Promise { - const request = await this.userVerificationService.buildRequest(verification); + async deleteCredential(credentialId: string, request: SecretVerificationRequest): Promise { await this.apiService.send("POST", `/webauthn/${credentialId}/delete`, request, true, true); } } diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts index 070513f19e8..1e4f1fa7717 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.spec.ts @@ -1,5 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; + import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { WebauthnLoginApiService } from "./webauthn-login-api.service"; @@ -7,6 +9,7 @@ import { WebauthnLoginService } from "./webauthn-login.service"; describe("WebauthnService", () => { let apiService!: MockProxy; + let userVerificationService!: MockProxy; let credentials: MockProxy; let webauthnService!: WebauthnLoginService; @@ -15,8 +18,9 @@ describe("WebauthnService", () => { window.PublicKeyCredential = class {} as any; window.AuthenticatorAttestationResponse = class {} as any; apiService = mock(); + userVerificationService = mock(); credentials = mock(); - webauthnService = new WebauthnLoginService(apiService, credentials); + webauthnService = new WebauthnLoginService(apiService, userVerificationService, credentials); }); describe("createCredential", () => { diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts index 760214961a7..c5979f08c61 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login.service.ts @@ -1,6 +1,7 @@ import { Injectable, Optional } from "@angular/core"; import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Verification } from "@bitwarden/common/types/verification"; @@ -11,8 +12,10 @@ import { SaveCredentialRequest } from "./request/save-credential.request"; import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request"; import { WebauthnLoginApiService } from "./webauthn-login-api.service"; -@Injectable() +@Injectable({ providedIn: "root" }) export class WebauthnLoginService { + static readonly MaxCredentialCount = 5; + private navigatorCredentials: CredentialsContainer; private _refresh$ = new BehaviorSubject(undefined); private _loading$ = new BehaviorSubject(true); @@ -27,6 +30,7 @@ export class WebauthnLoginService { constructor( private apiService: WebauthnLoginApiService, + private userVerificationService: UserVerificationService, @Optional() navigatorCredentials?: CredentialsContainer, @Optional() private logService?: LogService ) { @@ -37,7 +41,8 @@ export class WebauthnLoginService { async getCredentialCreateOptions( verification: Verification ): Promise { - const response = await this.apiService.getCredentialCreateOptions(verification); + const request = await this.userVerificationService.buildRequest(verification); + const response = await this.apiService.getCredentialCreateOptions(request); return new CredentialCreateOptionsView(response.options, response.token); } @@ -95,7 +100,8 @@ export class WebauthnLoginService { } async deleteCredential(credentialId: string, verification: Verification): Promise { - await this.apiService.deleteCredential(credentialId, verification); + const request = await this.userVerificationService.buildRequest(verification); + await this.apiService.deleteCredential(credentialId, request); this.refresh(); } diff --git a/apps/web/src/app/auth/settings/settings.module.ts b/apps/web/src/app/auth/settings/settings.module.ts index 282524d07e4..12ae6bcbf5e 100644 --- a/apps/web/src/app/auth/settings/settings.module.ts +++ b/apps/web/src/app/auth/settings/settings.module.ts @@ -11,6 +11,6 @@ import { WebauthnLoginSettingsModule } from "./webauthn-login-settings"; imports: [SharedModule, WebauthnLoginSettingsModule, PasswordCalloutComponent], declarations: [ChangePasswordComponent], providers: [], - exports: [WebauthnLoginSettingsModule, ChangePasswordComponent], + exports: [ChangePasswordComponent], }) -export class SettingsModule {} +export class AuthSettingsModule {} diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html index 57a2c545ca1..aadcf5e5960 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.html @@ -1,5 +1,5 @@
- + {{ "loginWithPasskey" | i18n }} {{ "newPasskey" | i18n }} diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts index 5c93d6f25e2..12af83cac5c 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts @@ -46,6 +46,7 @@ export class CreateCredentialDialogComponent implements OnInit { protected credentialOptions?: CredentialCreateOptionsView; protected deviceResponse?: PublicKeyCredential; protected hasPasskeys$?: Observable; + protected loading$ = this.webauthnService.loading$; constructor( private formBuilder: FormBuilder, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html index 4cfdbbcf7fe..5e87f6d4adf 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.html @@ -1,5 +1,5 @@ - + {{ "removePasskey" | i18n }} {{ diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts index 7cb03238392..9ee1337ffb2 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/delete-credential-dialog/delete-credential-dialog.component.ts @@ -27,6 +27,7 @@ export class DeleteCredentialDialogComponent implements OnInit, OnDestroy { masterPassword: ["", [Validators.required]], }); protected credential?: WebauthnCredentialView; + protected loading$ = this.webauthnService.loading$; constructor( @Inject(DIALOG_DATA) private params: DeleteCredentialDialogParams, diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html index 23abe02665c..5896d461bfb 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/webauthn-login-settings.component.html @@ -22,7 +22,7 @@

- - - + Date: Fri, 13 Oct 2023 00:56:42 +1000 Subject: [PATCH 05/85] [AC-1638] Disallow Secrets Manager for MSP-managed organizations (#6392) * Hide Add SM component on sub page for MSPs * Hide Add SM component on create org page for MSPs * Use hasProvider instead of providerType --- .../organizations/organization-plans.component.html | 2 +- .../organizations/organization-plans.component.ts | 10 +++++++--- .../organization-subscription-cloud.component.ts | 1 + 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 06d04dc4e41..87a8ad61627 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -276,7 +276,7 @@

{{ "summary" | i18n }}

Date: Thu, 12 Oct 2023 18:32:46 -0400 Subject: [PATCH 06/85] [PM-795] Fix send name missing ellipsis (#6538) * fix send name missing ellipsis * fix wrapping text on send items --- apps/desktop/src/scss/list.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/src/scss/list.scss b/apps/desktop/src/scss/list.scss index ec56eaa6c88..39e520f7d89 100644 --- a/apps/desktop/src/scss/list.scss +++ b/apps/desktop/src/scss/list.scss @@ -120,8 +120,12 @@ .item-content { display: block; + overflow-x: hidden; .item-title { display: block; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; .title-badges { @include themify($themes) { color: themed("mutedColor"); From 725acf5f7f1a620ffcf5a6094ffb8987f916e4ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Oct 2023 08:17:36 +0000 Subject: [PATCH 07/85] Autosync the updated translations (#6571) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/browser/src/_locales/bg/messages.json | 4 ++-- apps/browser/src/_locales/fa/messages.json | 2 +- apps/browser/src/_locales/sr/messages.json | 16 ++++++++-------- apps/browser/src/_locales/sv/messages.json | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index c96fadbd422..75d86dd2b41 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -83,7 +83,7 @@ "message": "Копиране на потребителското име" }, "copyNumber": { - "message": "Копиране на номера" + "message": "Копиране на но̀мера" }, "copySecurityCode": { "message": "Копиране на кода за сигурност" @@ -257,7 +257,7 @@ "message": "Избор" }, "generatePassword": { - "message": "Генериране на парола" + "message": "Нова парола" }, "regeneratePassword": { "message": "Регенериране на паролата" diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index bcfab0cdbc8..c366c3a00b6 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -726,7 +726,7 @@ "message": "درباره سازمان‌ها اطلاعات کسب کنید" }, "learnOrgConfirmation": { - "message": "Bitwarden به شما اجازه می‌دهد با استفاده از سازماندهی، موارد گاوصندوق خود را با دیگران به اشتراک بگذارید. آیا مایل به بازدید از وب سایت bitwarden.com برای کسب اطلاعات بیشتر هستید؟" + "message": "Bitwarden به شما اجازه می‌دهد با استفاده از سازمان، موارد گاوصندوق خود را با دیگران به اشتراک بگذارید. آیا مایل به بازدید از وب سایت bitwarden.com برای کسب اطلاعات بیشتر هستید؟" }, "moveToOrganization": { "message": "انتقال به سازمان" diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index d55a5bfe197..c496e7628d9 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -92,13 +92,13 @@ "message": "Аутоматско допуњавање" }, "autoFillLogin": { - "message": "Auto-fill login" + "message": "Ауто-пуњење пријаве" }, "autoFillCard": { - "message": "Auto-fill card" + "message": "Ауто-пуњење картице" }, "autoFillIdentity": { - "message": "Auto-fill identity" + "message": "Ауто-пуњење идентитета" }, "generatePasswordCopied": { "message": "Генериши Лозинку (копирано)" @@ -110,19 +110,19 @@ "message": "Нема одговарајућих пријављивања." }, "noCards": { - "message": "No cards" + "message": "Нема карте" }, "noIdentities": { - "message": "No identities" + "message": "Нема идентитета" }, "addLoginMenu": { - "message": "Add login" + "message": "Нема пријаве" }, "addCardMenu": { - "message": "Add card" + "message": "Додати картицу" }, "addIdentityMenu": { - "message": "Add identity" + "message": "Додати идентитет" }, "unlockVaultMenu": { "message": "Откључај свој сеф" diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index ca0b8de6580..283665f4977 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -92,10 +92,10 @@ "message": "Fyll i automatiskt" }, "autoFillLogin": { - "message": "Auto-fill login" + "message": "Autofyll inloggning" }, "autoFillCard": { - "message": "Auto-fill card" + "message": "Autofyll kort" }, "autoFillIdentity": { "message": "Auto-fill identity" From b592b71df197b40935ddf343788f8386176f27a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Oct 2023 08:24:02 +0000 Subject: [PATCH 08/85] Autosync the updated translations (#6570) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/web/src/locales/af/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ar/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/az/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/be/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/bg/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/bn/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/bs/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ca/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/cs/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/cy/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/da/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/de/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/el/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/en_GB/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/en_IN/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/eo/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/es/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/et/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/eu/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/fa/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/fi/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/fil/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/fr/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/gl/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/he/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/hi/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/hr/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/hu/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/id/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/it/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ja/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ka/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/km/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/kn/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ko/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/lv/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ml/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/mr/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/my/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/nb/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ne/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/nl/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/nn/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/or/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/pl/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/pt_BR/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/pt_PT/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ro/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/ru/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/si/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/sk/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/sl/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/sr/messages.json | 90 ++++++++++++++++++++++-- apps/web/src/locales/sr_CS/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/sv/messages.json | 88 ++++++++++++++++++++++- apps/web/src/locales/te/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/th/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/tr/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/uk/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/vi/messages.json | 86 +++++++++++++++++++++- apps/web/src/locales/zh_CN/messages.json | 88 ++++++++++++++++++++++- apps/web/src/locales/zh_TW/messages.json | 86 +++++++++++++++++++++- 62 files changed, 5212 insertions(+), 128 deletions(-) diff --git a/apps/web/src/locales/af/messages.json b/apps/web/src/locales/af/messages.json index 8eaf6e1f011..017e170ecd3 100644 --- a/apps/web/src/locales/af/messages.json +++ b/apps/web/src/locales/af/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Teken aan met hoofwagwoord" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Skep rekening" }, @@ -5406,6 +5472,19 @@ "required": { "message": "vereis" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Maksimum $MAX$ karakters", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Aan" }, + "off": { + "message": "Off" + }, "members": { "message": "Lede" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/ar/messages.json b/apps/web/src/locales/ar/messages.json index cd5c185c172..19971c613b7 100644 --- a/apps/web/src/locales/ar/messages.json +++ b/apps/web/src/locales/ar/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "تسجيل الدخول باستخدام كلمة المرور الرئيسية" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "إنشاء حساب" }, @@ -5406,6 +5472,19 @@ "required": { "message": "مطلوب" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "الأعضاء" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/az/messages.json b/apps/web/src/locales/az/messages.json index 1822746405e..e115c210540 100644 --- a/apps/web/src/locales/az/messages.json +++ b/apps/web/src/locales/az/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Ana parolla giriş et" }, + "loginWithPasskey": { + "message": "Parolla giriş et" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "Yeni parol" + }, + "learnMoreAboutPasswordless": { + "message": "Parolsuz haqqında daha ətraflı" + }, + "passkeyEnterMasterPassword": { + "message": "Parol ayarlarına sahib girişi dəyişdirmək üçün ana parolunuzu daxil edin." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Özəl ad" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Şifrələmə dəstəklənmir" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saxlanıldı", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Parol silindi" + }, + "removePasskey": { + "message": "Parolu sil" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Yenidən sına" + }, "createAccount": { "message": "Hesab yarat" }, @@ -5406,6 +5472,19 @@ "required": { "message": "tələb olunur" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ simvol", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "maksimum $MAX$ simvol", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Açıqdır" }, + "off": { + "message": "Bağlı" + }, "members": { "message": "Üzvlər" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "\"Secrets Manager\"ə abunə ol" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/be/messages.json b/apps/web/src/locales/be/messages.json index fbefebf78a1..cbebeeacf1f 100644 --- a/apps/web/src/locales/be/messages.json +++ b/apps/web/src/locales/be/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Увайсці з асноўным паролем" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Стварыць уліковы запіс" }, @@ -5406,6 +5472,19 @@ "required": { "message": "патрабуецца" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Максімум $MAX$ сімвалаў", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Уключана" }, + "off": { + "message": "Off" + }, "members": { "message": "Удзельнікі" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Дадаць менеджар сакрэтаў" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Дадаць менеджар сакрэтаў у ваш абноўлены тарыфны план, каб падтрымліваць доступ да любых сакрэтаў, якія былі створаны з дапамогай вашых папярэдніх тарыфных планаў." diff --git a/apps/web/src/locales/bg/messages.json b/apps/web/src/locales/bg/messages.json index f6036998998..5074795c58a 100644 --- a/apps/web/src/locales/bg/messages.json +++ b/apps/web/src/locales/bg/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Вписване с главната парола" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Нов опит" + }, "createAccount": { "message": "Създаване на абонамент" }, @@ -5406,6 +5472,19 @@ "required": { "message": "задължително" }, + "charactersCurrentAndMaximum": { + "message": "Максимален брой знаци: $CURRENT$/$MAX$", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Максимален брой знаци: $MAX$", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Включено" }, + "off": { + "message": "Изключено" + }, "members": { "message": "Членове" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Добавяне на Управление на тайни" + "subscribeToSecretsManager": { + "message": "Абониране за Управлението на тайни" }, "addSecretsManagerUpgradeDesc": { "message": "Добавете Управление на тайни към своя надграден план, за да продължите да имате достъп до тайните създадени при предишния Ви план." diff --git a/apps/web/src/locales/bn/messages.json b/apps/web/src/locales/bn/messages.json index 25f2a837f52..c111b48b444 100644 --- a/apps/web/src/locales/bn/messages.json +++ b/apps/web/src/locales/bn/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "অ্যাকাউন্ট তৈরি" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/bs/messages.json b/apps/web/src/locales/bs/messages.json index 7fa48aeaa5e..c78a2207f5e 100644 --- a/apps/web/src/locales/bs/messages.json +++ b/apps/web/src/locales/bs/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/ca/messages.json b/apps/web/src/locales/ca/messages.json index 30ee2f589f3..96f1b5596d4 100644 --- a/apps/web/src/locales/ca/messages.json +++ b/apps/web/src/locales/ca/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Inici de sessió amb contrasenya mestra" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Crea un compte" }, @@ -5406,6 +5472,19 @@ "required": { "message": "requerit" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ caràcters màxim", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Activat" }, + "off": { + "message": "Off" + }, "members": { "message": "Membres" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Afig administrador de secrets" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Afegiu administrador de secrets al vostre pla actualitzat per mantindre l'accés a tots els secrets creats amb el vostre pla anterior." diff --git a/apps/web/src/locales/cs/messages.json b/apps/web/src/locales/cs/messages.json index 24e3817f615..843c805089c 100644 --- a/apps/web/src/locales/cs/messages.json +++ b/apps/web/src/locales/cs/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Přihlásit se pomocí hlavního hesla" }, + "loginWithPasskey": { + "message": "Přihlásit se pomocí přístupového klíče" + }, + "loginWithPasskeyInfo": { + "message": "Použijte vygenerovaný přístupový klíč, který Vás automaticky přihlásí bez hesla. Biometriky, jako je rozpoznávání obličeje, otisky prstů nebo jiná bezpečnostní metoda FIDO2 ověří Vaši totožnost." + }, + "newPasskey": { + "message": "Nové přístupový klíč" + }, + "learnMoreAboutPasswordless": { + "message": "Další informace o přihlášení bez hesla" + }, + "passkeyEnterMasterPassword": { + "message": "Zadejte Vaše hlavní heslo pro úpravu dvoufázového přihlášení." + }, + "creatingPasskeyLoading": { + "message": "Vytváření přístupového klíče..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Ponechte toto okno otevřené a postupujte podle dotazů z Vašeho prohlížeče." + }, + "errorCreatingPasskey": { + "message": "Chyba při vytváření přístupového klíče" + }, + "errorCreatingPasskeyInfo": { + "message": "Vyskytl se problém při vytváření přístupového klíče." + }, + "passkeySuccessfullyCreated": { + "message": "Přístupový klíč byl úspěšně vytvořen!" + }, + "customName": { + "message": "Vlastní název" + }, + "customPasskeyNameInfo": { + "message": "Pojmenujte Váš přístupový klíč, abyste jej mohli snáze identifikovat." + }, + "encryptionNotSupported": { + "message": "Šifrování není podporováno" + }, + "loginWithPasskeyEnabled": { + "message": "Přihlásit se se zapnutým přístupovým klíčem" + }, + "passkeySaved": { + "message": "$NAME$ uloženo", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Přístupový klíč byl odebrán" + }, + "removePasskey": { + "message": "Odebrat přístupový klíč" + }, + "removePasskeyInfo": { + "message": "Pokud budou odebrány všechny přístupové klíče, nebudete se moci přihlásit do nových zařízení bez hlavního hesla." + }, + "passkeyLimitReachedInfo": { + "message": "Byl dosažen limit přístupového klíče. Chcete-li přidat další, odeberte nějaký." + }, + "tryAgain": { + "message": "Zkusit znovu" + }, "createAccount": { "message": "Vytvořit účet" }, @@ -5406,6 +5472,19 @@ "required": { "message": "vyžadováno" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ znaků maximálně", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Maximálně $MAX$ znaků", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Zapnuto" }, + "off": { + "message": "VYP." + }, "members": { "message": "Členové" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Přidat správce tajných klíčů" + "subscribeToSecretsManager": { + "message": "Přihlásit se ke správci klíčů" }, "addSecretsManagerUpgradeDesc": { "message": "Přidáním správce klíčů k aktualizovanému plánu si zachováte přístup ke všem klíčům vytvořeným v předchozím plánu." diff --git a/apps/web/src/locales/cy/messages.json b/apps/web/src/locales/cy/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/cy/messages.json +++ b/apps/web/src/locales/cy/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/da/messages.json b/apps/web/src/locales/da/messages.json index 8de532e7e26..d5db4ca110e 100644 --- a/apps/web/src/locales/da/messages.json +++ b/apps/web/src/locales/da/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log ind med hovedadgangskode" }, + "loginWithPasskey": { + "message": "Log ind med adgangsnøgle" + }, + "loginWithPasskeyInfo": { + "message": "Brug en genereret adgangsnøgle, der automatisk logger dig ind uden en adgangskode. Biometri, såsom ansigtsgenkendelse eller fingeraftryk, eller en anden FIDO2-sikkerhedsmetode, vil bekræfte din identitet." + }, + "newPasskey": { + "message": "Ny adgangsnøgle" + }, + "learnMoreAboutPasswordless": { + "message": "Læs mere om adgangskodefrihed" + }, + "passkeyEnterMasterPassword": { + "message": "Angiv hovedadgangskoden for at ændre loginindstillinger for adgangsnøgle." + }, + "creatingPasskeyLoading": { + "message": "Opretter adgangsnøgle..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Hold dette vindue åbent, og følg prompterne fra webbrowseren." + }, + "errorCreatingPasskey": { + "message": "Fejl ved oprettelse af adgangsnøgle" + }, + "errorCreatingPasskeyInfo": { + "message": "Der opstod et problem med at oprette adgangsnøglen." + }, + "passkeySuccessfullyCreated": { + "message": "Adgangsnøgle hermed oprettet!" + }, + "customName": { + "message": "Tilpasset navn" + }, + "customPasskeyNameInfo": { + "message": "Navngiv adgangsnøglen for at hjælpe dig med at identificere den." + }, + "encryptionNotSupported": { + "message": "Kryptering ikke understøttet" + }, + "loginWithPasskeyEnabled": { + "message": "Log ind med adgangsnøgle slået til" + }, + "passkeySaved": { + "message": "$NAME$ gemt", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Adgangsnøgle fjernet" + }, + "removePasskey": { + "message": "Fjern adgangsnøgle" + }, + "removePasskeyInfo": { + "message": "Fjernes alle adgangsnøgler, vil du ikke kunne logge ind på nye enheder uden hovedadgangskoden." + }, + "passkeyLimitReachedInfo": { + "message": "Adgangsnøglekvote nået. Fjern en adgangsnøgle for at tilføje en anden." + }, + "tryAgain": { + "message": "Forsøg igen" + }, "createAccount": { "message": "Opret konto" }, @@ -5406,6 +5472,19 @@ "required": { "message": "obligatorisk" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ tegn maksimalt", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Maksimalt $MAX$ tegn", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Til" }, + "off": { + "message": "Fra" + }, "members": { "message": "Medlemmer" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Tilføj Secrets Manager" + "subscribeToSecretsManager": { + "message": "Abonnér på Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Føj Secrets Manager til den opgraderede abonnementstype for at bibeholde adgang til alle hemmeligheder oprettet med det tidligere abonnement." diff --git a/apps/web/src/locales/de/messages.json b/apps/web/src/locales/de/messages.json index 09c18f83434..f988a6b988f 100644 --- a/apps/web/src/locales/de/messages.json +++ b/apps/web/src/locales/de/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Mit Master-Passwort anmelden" }, + "loginWithPasskey": { + "message": "Mit Passkey anmelden" + }, + "loginWithPasskeyInfo": { + "message": "Verwende einen generierten Passkey, der dich automatisch ohne Passwort anmeldet. Biometrische Merkmale wie Gesichtserkennung oder Fingerabdruck oder eine andere FIDO2-Sicherheitsmethode verifizieren deine Identität." + }, + "newPasskey": { + "message": "Neuer Passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Gib dein Master-Passwort ein, um die Einstellungen für die Anmeldung mit Passkeys zu ändern." + }, + "creatingPasskeyLoading": { + "message": "Passkey wird erstellt..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Behalte dieses Fenster offen und folge den Anweisungen deines Browsers." + }, + "errorCreatingPasskey": { + "message": "Fehler beim Erstellen des Passkeys." + }, + "errorCreatingPasskeyInfo": { + "message": "Es gab ein Problem beim Erstellen deines Passkeys." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey erfolgreich erstellt!" + }, + "customName": { + "message": "Benutzerdefinierter Name" + }, + "customPasskeyNameInfo": { + "message": "Benenne deinen Passkey, um ihn zu identifizieren." + }, + "encryptionNotSupported": { + "message": "Verschlüsselung nicht unterstützt" + }, + "loginWithPasskeyEnabled": { + "message": "Anmeldung mit Passkey aktiviert" + }, + "passkeySaved": { + "message": "$NAME$ gespeichert", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey gelöscht" + }, + "removePasskey": { + "message": "Passkey löschen" + }, + "removePasskeyInfo": { + "message": "Wenn alle Passkeys entfernt werden, kannst du dich ohne dein Master-Passwort nicht auf neuen Geräten anmelden." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey-Limit erreicht. Entferne einen Passkey, um ein weiteren hinzuzufügen." + }, + "tryAgain": { + "message": "Erneut versuchen" + }, "createAccount": { "message": "Konto erstellen" }, @@ -5406,6 +5472,19 @@ "required": { "message": "erforderlich" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ erlaubten Zeichen", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Maximal $MAX$ Zeichen", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Ein" }, + "off": { + "message": "Aus" + }, "members": { "message": "Mitglieder" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Secrets Manager hinzufügen" + "subscribeToSecretsManager": { + "message": "Secrets Manager abonnieren" }, "addSecretsManagerUpgradeDesc": { "message": "Füge den Secrets Manager zu deinem aktualisierten Abonnement hinzu, um den Zugriff auf alle Geheimnisse zu erhalten, die mit deinem vorherigen Abonnement erstellt wurden." diff --git a/apps/web/src/locales/el/messages.json b/apps/web/src/locales/el/messages.json index 5004408eafa..55ebf648905 100644 --- a/apps/web/src/locales/el/messages.json +++ b/apps/web/src/locales/el/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Συνδεθείτε με τον κύριο κωδικό πρόσβασης" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Δημιουργία Λογαριασμού" }, @@ -5406,6 +5472,19 @@ "required": { "message": "απαιτείται" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/en_GB/messages.json b/apps/web/src/locales/en_GB/messages.json index be316df1621..b91dfccd05f 100644 --- a/apps/web/src/locales/en_GB/messages.json +++ b/apps/web/src/locales/en_GB/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/en_IN/messages.json b/apps/web/src/locales/en_IN/messages.json index 277641ca9a0..da2447e4df4 100644 --- a/apps/web/src/locales/en_IN/messages.json +++ b/apps/web/src/locales/en_IN/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/eo/messages.json b/apps/web/src/locales/eo/messages.json index 77407c53c8f..d44056e11f6 100644 --- a/apps/web/src/locales/eo/messages.json +++ b/apps/web/src/locales/eo/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Saluti kun la ĉefpasvorto" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Krei konton" }, @@ -5406,6 +5472,19 @@ "required": { "message": "deviga" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Ŝaltita" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/es/messages.json b/apps/web/src/locales/es/messages.json index 18d85d28661..0b6740ffe3c 100644 --- a/apps/web/src/locales/es/messages.json +++ b/apps/web/src/locales/es/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Iniciar sesión con contraseña maestra" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Crear cuenta" }, @@ -5406,6 +5472,19 @@ "required": { "message": "requerido" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ caracteres como máximo", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Activado" }, + "off": { + "message": "Off" + }, "members": { "message": "Miembros" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/et/messages.json b/apps/web/src/locales/et/messages.json index 97f5c17bf81..bcb56734b17 100644 --- a/apps/web/src/locales/et/messages.json +++ b/apps/web/src/locales/et/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Logi sisse ülemparooliga" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Konto loomine" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/eu/messages.json b/apps/web/src/locales/eu/messages.json index 92d3823cd1e..569edfc4751 100644 --- a/apps/web/src/locales/eu/messages.json +++ b/apps/web/src/locales/eu/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Hasi saioa pasahitz nagusiarekin" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Sortu kontua" }, @@ -5406,6 +5472,19 @@ "required": { "message": "beharrezkoa" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Piztuta" }, + "off": { + "message": "Off" + }, "members": { "message": "Kideak" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/fa/messages.json b/apps/web/src/locales/fa/messages.json index e564086f871..2c9af8a8dbd 100644 --- a/apps/web/src/locales/fa/messages.json +++ b/apps/web/src/locales/fa/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "با کلمه عبور اصلی وارد شوید" }, + "loginWithPasskey": { + "message": "با کلید عبور وارد شوید" + }, + "loginWithPasskeyInfo": { + "message": "از یک کلمه عبور ایجاد شده استفاده کنید که به طور خودکار بدون کلمه عبور شما را وارد می‌کند. بیومتریک‌ها، مانند تشخیص چهره یا اثر انگشت، یا سایر روش‌های امنیتی FIDO2 هویت شما را تأیید می‌کنند." + }, + "newPasskey": { + "message": "کلمه عبور جدید" + }, + "learnMoreAboutPasswordless": { + "message": "درباره بدون کلمه عبور بیشتر بدانید" + }, + "passkeyEnterMasterPassword": { + "message": "کلمه عبور اصلی خود را برای تغییر ورود با تنظیمات کلید عبور وارد کنید." + }, + "creatingPasskeyLoading": { + "message": "ایجاد کلید عبور..." + }, + "creatingPasskeyLoadingInfo": { + "message": "این پنجره را باز نگه دارید و دستورهای مرورگر خود را دنبال کنید." + }, + "errorCreatingPasskey": { + "message": "خطا در ایجاد کلید عبور" + }, + "errorCreatingPasskeyInfo": { + "message": "مشکلی در ایجاد کلید عبور شما وجود داشت." + }, + "passkeySuccessfullyCreated": { + "message": "کلید عبور با موفقیت ایجاد شد!" + }, + "customName": { + "message": "نام سفارشی" + }, + "customPasskeyNameInfo": { + "message": "کلید عبور خود را برای کمک به شناسایی آن نام ببرید." + }, + "encryptionNotSupported": { + "message": "رمزگذاری پشتیبانی نمی‌شود" + }, + "loginWithPasskeyEnabled": { + "message": "با فعال بودن کلید ورود وارد شوید" + }, + "passkeySaved": { + "message": "$NAME$ ذخیره شد", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "کلید عبور حذف شد" + }, + "removePasskey": { + "message": "حذف کلید عبور" + }, + "removePasskeyInfo": { + "message": "اگر همه کلیدهای عبور حذف شوند، نمی‌توانید بدون کلمه عبور اصلی خود وارد دستگاه‌های جدید شوید." + }, + "passkeyLimitReachedInfo": { + "message": "به حد مجاز کلید عبور رسیده است. برای افزودن کلید عبور دیگر، کلید عبور را حذف کنید." + }, + "tryAgain": { + "message": "دوباره امتحان کن" + }, "createAccount": { "message": "ایجاد حساب کاربری" }, @@ -5406,6 +5472,19 @@ "required": { "message": "الزامی است" }, + "charactersCurrentAndMaximum": { + "message": "حداکثر کاراکتر $CURRENT$/$MAX$", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "حداکثر کاراکتر $MAX$", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "روشن" }, + "off": { + "message": "خاموش" + }, "members": { "message": "اعضا" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "افزودن مدیر رازها" + "subscribeToSecretsManager": { + "message": "دنبال کردن مدیر اسرار" }, "addSecretsManagerUpgradeDesc": { "message": "مدیر رازها را به برنامه ارتقا یافته خود اضافه کنید تا دسترسی به رازهای ایجاد شده با برنامه قبلی خود را حفظ کنید." diff --git a/apps/web/src/locales/fi/messages.json b/apps/web/src/locales/fi/messages.json index bb2d7ae1995..01368eada3e 100644 --- a/apps/web/src/locales/fi/messages.json +++ b/apps/web/src/locales/fi/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Kirjaudu pääsalasanalla" }, + "loginWithPasskey": { + "message": "Kirjaudu suojausavaimella" + }, + "loginWithPasskeyInfo": { + "message": "Käytä generoitua suojausavainta, joka kirjaa sinut automaattisesti sisään ilman salasanaa. Henkilöllisyytesi vahvistetaan kasvojen tunnistuksen tai sormenjäljen kataisille biometrisillä tiedoilla, tai jollakin muulla FIDO2-suojausratkaisulla." + }, + "newPasskey": { + "message": "Uusi suojausavain" + }, + "learnMoreAboutPasswordless": { + "message": "Luo lisää salasanattomasta kirjautumisesta" + }, + "passkeyEnterMasterPassword": { + "message": "Syötä pääsalasanasi muokataksesi suojausavaimella kirjautumisen asetuksia." + }, + "creatingPasskeyLoading": { + "message": "Suojausavainta luodaan..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Pidä tämä ikkuna avoinna ja seuraa selaimesi opasteita." + }, + "errorCreatingPasskey": { + "message": "Virhe suojausavaimen luonnissa" + }, + "errorCreatingPasskeyInfo": { + "message": "Suojausavaimesi luonnissa kohdattiin ongelma." + }, + "passkeySuccessfullyCreated": { + "message": "Suojausavain on luotu!" + }, + "customName": { + "message": "Mukautettu nimi" + }, + "customPasskeyNameInfo": { + "message": "Anna suojausavaimellesi nimi, josta tunnistat sen." + }, + "encryptionNotSupported": { + "message": "Salausta ei tueta" + }, + "loginWithPasskeyEnabled": { + "message": "Suojausavaimella kirjautuminen on käytössä" + }, + "passkeySaved": { + "message": "$NAME$ tallennettiin", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Suojausavain poistettiin" + }, + "removePasskey": { + "message": "POista suojausavain" + }, + "removePasskeyInfo": { + "message": "Jos kaikki suojausavaimet poistetaan, et voi kirjautua uusille laitteille ilman pääsalasanaasi." + }, + "passkeyLimitReachedInfo": { + "message": "Suojausavianten enimmäismäärä on saavutettu. Lisää suojausavain poistamalla jokin nykyisistä avaimista." + }, + "tryAgain": { + "message": "Yritä uudelleen" + }, "createAccount": { "message": "Luo uusi tili" }, @@ -5406,6 +5472,19 @@ "required": { "message": "pakollinen" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$ merkkiä (enimmäismäärä on $MAX$)", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Enintään $MAX$ merkkiä", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Käytössä" }, + "off": { + "message": "Ei käytössä" + }, "members": { "message": "Jäsenet" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Lisää Salaisuushallinta" + "subscribeToSecretsManager": { + "message": "Tilaa Salaisuushallinta" }, "addSecretsManagerUpgradeDesc": { "message": "Lisää Salaisuushallinta myös päivitettyyn tilaukseesi, jottet menetä pääsyä aiemmalla tilauksellasi luotuihin salaisuuksiin." diff --git a/apps/web/src/locales/fil/messages.json b/apps/web/src/locales/fil/messages.json index b68a81ca3e7..4a238724903 100644 --- a/apps/web/src/locales/fil/messages.json +++ b/apps/web/src/locales/fil/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Mag-log in gamit ang master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Gumawa ng account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "kailangan" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "I-on" }, + "off": { + "message": "Off" + }, "members": { "message": "Mga Miyembro" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/fr/messages.json b/apps/web/src/locales/fr/messages.json index d02cfef1541..4c08e598cac 100644 --- a/apps/web/src/locales/fr/messages.json +++ b/apps/web/src/locales/fr/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Se connecter avec le mot de passe principal" }, + "loginWithPasskey": { + "message": "Se connecter avec la clé d'accès" + }, + "loginWithPasskeyInfo": { + "message": "Utilisez une clé d'accès générée qui vous connectera automatiquement sans mot de passe. Les fonctions biométriques, comme la reconnaissance faciale, l'empreinte digitale, ou une autre méthode de sécurité FIDO2 vérifiera votre identité." + }, + "newPasskey": { + "message": "Nouvelle clé d'accès" + }, + "learnMoreAboutPasswordless": { + "message": "En savoir plus sur l'identification sans mots de passe" + }, + "passkeyEnterMasterPassword": { + "message": "Entrez votre mot de passe maître pour modifier la connexion avec les paramètres de clé d'accès." + }, + "creatingPasskeyLoading": { + "message": "Création de la clé d'accès..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Gardez cette fenêtre ouverte et suivez les instructions de votre navigateur." + }, + "errorCreatingPasskey": { + "message": "Erreur de création de la clé d'accès" + }, + "errorCreatingPasskeyInfo": { + "message": "Il y a eu un problème lors de la crétion de votre clé d'accès." + }, + "passkeySuccessfullyCreated": { + "message": "Clé d'accès créée avec succès!" + }, + "customName": { + "message": "Nom personnalisé" + }, + "customPasskeyNameInfo": { + "message": "Nommez votre clé d'accès pour vous aider à l'identifier." + }, + "encryptionNotSupported": { + "message": "Chiffrement non pris en charge" + }, + "loginWithPasskeyEnabled": { + "message": "Se connecter avec la clé d'accès activée" + }, + "passkeySaved": { + "message": "$NAME$ a été enregistrée", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Clé d'accès supprimée" + }, + "removePasskey": { + "message": "Supprimer la clé d'accès" + }, + "removePasskeyInfo": { + "message": "Si toutes les clés d'accès sont supprimées, vous ne pourrez pas vous connecter à de nouveaux appareils sans votre mot de passe principal." + }, + "passkeyLimitReachedInfo": { + "message": "Limite de clé d'accès atteinte. Supprimez une clé d'accès pour en ajouter une autre." + }, + "tryAgain": { + "message": "Essayez de nouveau" + }, "createAccount": { "message": "Créez un compte" }, @@ -5406,6 +5472,19 @@ "required": { "message": "requis" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ caractères maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ caractères maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Activé" }, + "off": { + "message": "Désactivée" + }, "members": { "message": "Membres" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Ajouter Secrets Manager" + "subscribeToSecretsManager": { + "message": "S'abonner au Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Ajoutez Secrets Manager à votre plan mis à niveau pour conserver l'accès à tous les secrets créés avec votre plan précédent." diff --git a/apps/web/src/locales/gl/messages.json b/apps/web/src/locales/gl/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/gl/messages.json +++ b/apps/web/src/locales/gl/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/he/messages.json b/apps/web/src/locales/he/messages.json index 70f447848d9..9125060f521 100644 --- a/apps/web/src/locales/he/messages.json +++ b/apps/web/src/locales/he/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "צור חשבון" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/hi/messages.json b/apps/web/src/locales/hi/messages.json index b6463a2d7cc..078fba139f2 100644 --- a/apps/web/src/locales/hi/messages.json +++ b/apps/web/src/locales/hi/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "खाता बनाएँ" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/hr/messages.json b/apps/web/src/locales/hr/messages.json index dbf22380177..0684cdb4a5a 100644 --- a/apps/web/src/locales/hr/messages.json +++ b/apps/web/src/locales/hr/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Prijava glavnom lozinkom" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Stvori račun" }, @@ -5406,6 +5472,19 @@ "required": { "message": "obavezno" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "najviše $MAX$ znakova", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Uključeno" }, + "off": { + "message": "Off" + }, "members": { "message": "Članovi" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/hu/messages.json b/apps/web/src/locales/hu/messages.json index faa4f61db77..d24afc3cce0 100644 --- a/apps/web/src/locales/hu/messages.json +++ b/apps/web/src/locales/hu/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Bejelentkezés mesterjelszóval" }, + "loginWithPasskey": { + "message": "Bejelentkezés jelszóval" + }, + "loginWithPasskeyInfo": { + "message": "Használjunk generált jelszót, amely automatikusan, jelszó nélkül bejelentkeztet. A biometrikus adatok, például az arcfelismerés vagy az ujjlenyomat, vagy más FIDO2 biztonsági módszer igazolja személyazonosságot." + }, + "newPasskey": { + "message": "Új jelszó" + }, + "learnMoreAboutPasswordless": { + "message": "További információ a jelszó nélküli használatról" + }, + "passkeyEnterMasterPassword": { + "message": "Adjuk meg a mesterjelszót a belépési jelszó beállításainak módosításához." + }, + "creatingPasskeyLoading": { + "message": "Jelszó létrehozása..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Tartsuk nyitva ezt az ablakot és kövessük a böngésző utasításait." + }, + "errorCreatingPasskey": { + "message": "Hiba történt a jelszó létrehozásakor." + }, + "errorCreatingPasskeyInfo": { + "message": "Probléma merült fel a jelszó létrehozásakor." + }, + "passkeySuccessfullyCreated": { + "message": "A jelszó sikeresen létrehozásra került!" + }, + "customName": { + "message": "Egyedi név" + }, + "customPasskeyNameInfo": { + "message": "Nevezzük el a jelszót a könnyebb azonosítshoz." + }, + "encryptionNotSupported": { + "message": "A titkosítás nem támogatott." + }, + "loginWithPasskeyEnabled": { + "message": "Jelentkezzünk be bekapcsolt jelszóval" + }, + "passkeySaved": { + "message": "$NAME$ mentésre került.", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "A jelszó eltávolításra került." + }, + "removePasskey": { + "message": "Jelszó eltávolítása" + }, + "removePasskeyInfo": { + "message": "Ha az összes jelszót eltávolítjuk, nem tudunk bejelentkezni az új eszközökre a mesterjelszó nélkül." + }, + "passkeyLimitReachedInfo": { + "message": "Elértük a jelszó korlátot. Távolítsunk el egy jelszót egy másik hozzáadásához." + }, + "tryAgain": { + "message": "Próbáluk újra" + }, "createAccount": { "message": "Fiók létrehozása" }, @@ -5406,6 +5472,19 @@ "required": { "message": "kötelező" }, + "charactersCurrentAndMaximum": { + "message": "Maximálisan $CURRENT$/$MAX$ karakter", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "maximálisan $MAX$ karakter", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Be" }, + "off": { + "message": "Ki" + }, "members": { "message": "Tagok" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Titkos kódkezelő hozzáadása" + "subscribeToSecretsManager": { + "message": "Feliratkozás a Titkos kód kezelés szolgáltatásra" }, "addSecretsManagerUpgradeDesc": { "message": "Titkkos kód kezelő hozzáadása a felminősített csomaghoz, hogy az előző csomaggal létrehozott titkos kódok megtartásához." diff --git a/apps/web/src/locales/id/messages.json b/apps/web/src/locales/id/messages.json index 13e5d53c17c..f5f4c05d888 100644 --- a/apps/web/src/locales/id/messages.json +++ b/apps/web/src/locales/id/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Masuk dengan kata sandi utama" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Buat Akun" }, @@ -5406,6 +5472,19 @@ "required": { "message": "diperlukan" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Anggota" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/it/messages.json b/apps/web/src/locales/it/messages.json index c726f0e5182..2e2c49876c2 100644 --- a/apps/web/src/locales/it/messages.json +++ b/apps/web/src/locales/it/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Accedi con password principale" }, + "loginWithPasskey": { + "message": "Accedi con passkey" + }, + "loginWithPasskeyInfo": { + "message": "Usa una passkey che ti farà accedere automaticamente senza una password. L'autenticazione biometrica, come il riconoscimento facciale o l'impronta digitale, o un altro metodo di sicurezza FIDO2 verificherà la tua identità." + }, + "newPasskey": { + "message": "Nuova passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Ulteriori informazioni sull'autenticazione senza password" + }, + "passkeyEnterMasterPassword": { + "message": "Inserisci la tua password principale per modificare le impostazioni di accesso con le passkey." + }, + "creatingPasskeyLoading": { + "message": "Creando la passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Mantieni questa finestra aperta e segui le istruzioni del tuo browser." + }, + "errorCreatingPasskey": { + "message": "Errore durante la creazione della passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "Si è verificato un problema durante la creazione della tua passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey creata!" + }, + "customName": { + "message": "Nome personalizzato" + }, + "customPasskeyNameInfo": { + "message": "Dai un nome alla tua passkey per aiutarti a riconoscerla." + }, + "encryptionNotSupported": { + "message": "Criptografia non supportata" + }, + "loginWithPasskeyEnabled": { + "message": "Accesso con passkey attivato" + }, + "passkeySaved": { + "message": "$NAME$ salvato", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey rimossa" + }, + "removePasskey": { + "message": "Rimuovi passkey" + }, + "removePasskeyInfo": { + "message": "Se tutte le passkey sono rimosse, non potrai accedere a nuovi dispositivi senza la tua password principale." + }, + "passkeyLimitReachedInfo": { + "message": "Limite di passkey raggiunto. Rimuovi una passkey per aggiungerne un'altra." + }, + "tryAgain": { + "message": "Riprova" + }, "createAccount": { "message": "Crea account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "obbligatorio" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$ caratteri su $MAX$", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Massimo $MAX$ caratteri", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Attivo" }, + "off": { + "message": "No" + }, "members": { "message": "Membri" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Aggiungi Gestore dei Segreti" + "subscribeToSecretsManager": { + "message": "Iscriviti al Gestore dei Segreti" }, "addSecretsManagerUpgradeDesc": { "message": "Aggiungi il Gestore dei Segreti al tuo piano aggiornato per mantenere l'accesso a tutti i secret creati con il tuo piano precedente." diff --git a/apps/web/src/locales/ja/messages.json b/apps/web/src/locales/ja/messages.json index 6ec8f42fdf3..c943906d601 100644 --- a/apps/web/src/locales/ja/messages.json +++ b/apps/web/src/locales/ja/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "マスターパスワードでログイン" }, + "loginWithPasskey": { + "message": "パスキー でログイン" + }, + "loginWithPasskeyInfo": { + "message": "パスワードなしで自動的にログインできる、生成されたパスキーを使用します。顔や指紋などの生体認証、または他の FIDO2 認証方式で本人確認を行います。" + }, + "newPasskey": { + "message": "新しいパスキー" + }, + "learnMoreAboutPasswordless": { + "message": "パスワードレスの詳細" + }, + "passkeyEnterMasterPassword": { + "message": "パスキー設定でのログインを変更するには、マスターパスワードを入力してください。" + }, + "creatingPasskeyLoading": { + "message": "パスキーを作成中..." + }, + "creatingPasskeyLoadingInfo": { + "message": "このウィンドウを開いたままにして、ブラウザの指示に従ってください。" + }, + "errorCreatingPasskey": { + "message": "パスキーの作成に失敗しました" + }, + "errorCreatingPasskeyInfo": { + "message": "パスキー作成中に問題が発生しました。" + }, + "passkeySuccessfullyCreated": { + "message": "パスキーを作成しました!" + }, + "customName": { + "message": "カスタム名" + }, + "customPasskeyNameInfo": { + "message": "パスキーに名前を付けて、識別しやすいようにしましょう。" + }, + "encryptionNotSupported": { + "message": "暗号化はサポートされていません" + }, + "loginWithPasskeyEnabled": { + "message": "パスキーをオンにしてログイン" + }, + "passkeySaved": { + "message": "$NAME$ を保存しました", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "パスキーを削除しました" + }, + "removePasskey": { + "message": "パスキーを削除" + }, + "removePasskeyInfo": { + "message": "すべてのパスキーを削除すると、マスターパスワードなしで新しいデバイスにログインすることはできません。" + }, + "passkeyLimitReachedInfo": { + "message": "パスキーの上限に達しました。追加するにはまず既存のパスキーを削除してください。" + }, + "tryAgain": { + "message": "再試行" + }, "createAccount": { "message": "アカウントの作成" }, @@ -5406,6 +5472,19 @@ "required": { "message": "必須" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ 文字", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "最大 $MAX$ 文字", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "オン" }, + "off": { + "message": "オフ" + }, "members": { "message": "メンバー" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "シークレットマネージャーを追加" + "subscribeToSecretsManager": { + "message": "シークレットマネージャーに登録" }, "addSecretsManagerUpgradeDesc": { "message": "アップグレードしたプランにシークレットマネージャーを追加して、以前のプランで作成されたシークレットへのアクセスを維持します。" diff --git a/apps/web/src/locales/ka/messages.json b/apps/web/src/locales/ka/messages.json index 9bfddfad59e..665c28b3b9c 100644 --- a/apps/web/src/locales/ka/messages.json +++ b/apps/web/src/locales/ka/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "ავტორიზაცია მთავარი პაროლით" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "ანგარიშის შექმნა" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/km/messages.json b/apps/web/src/locales/km/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/km/messages.json +++ b/apps/web/src/locales/km/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/kn/messages.json b/apps/web/src/locales/kn/messages.json index c349b08791a..d81d2a49b24 100644 --- a/apps/web/src/locales/kn/messages.json +++ b/apps/web/src/locales/kn/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "ಖಾತೆ ತೆರೆ" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/ko/messages.json b/apps/web/src/locales/ko/messages.json index 6e4a514c11a..66201c33c60 100644 --- a/apps/web/src/locales/ko/messages.json +++ b/apps/web/src/locales/ko/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "마스터 비밀번호로 로그인" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "계정 만들기" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "구성원" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/lv/messages.json b/apps/web/src/locales/lv/messages.json index fcfc4628e6b..30ea169447a 100644 --- a/apps/web/src/locales/lv/messages.json +++ b/apps/web/src/locales/lv/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Pieteikties ar galveno paroli" }, + "loginWithPasskey": { + "message": "Pieteikties ar piekļuves atslēgu" + }, + "loginWithPasskeyInfo": { + "message": "Piekļuves atslēga ir izmantojama, lai automātiski pieteiktos bez paroles. Biometrija, piemēram, sejas atpazīšana vai pirkstu nospiedums, vai cits FIDO2 drošības veids apstiprinās identitāti." + }, + "newPasskey": { + "message": "Jauna piekļuves atslēga" + }, + "learnMoreAboutPasswordless": { + "message": "Uzzināt vairāk par bezparoles pieteikšanos" + }, + "passkeyEnterMasterPassword": { + "message": "Jāievada galvenā parole, lai mainītu pieteikšanās ar piekļuves atslēgu iestatījumus." + }, + "creatingPasskeyLoading": { + "message": "Izveido piekļuves atslēgu..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Šis logs ir jāpatur atvērts un jāseko uzvednēm pārlūkā." + }, + "errorCreatingPasskey": { + "message": "Kļūda piekļuves atslēgas izveidošanā" + }, + "errorCreatingPasskeyInfo": { + "message": "Atgadījās kļūda piekļuves atslēgas izveidošanas laikā." + }, + "passkeySuccessfullyCreated": { + "message": "Piekļuves atslēga veiksmīģi izveidota." + }, + "customName": { + "message": "Pielāgots nosaukums" + }, + "customPasskeyNameInfo": { + "message": "Nosaukuma piešķiršana piekļuves atslēgai palīdz to atpazīt." + }, + "encryptionNotSupported": { + "message": "Šifrēšana nav atbalstīta" + }, + "loginWithPasskeyEnabled": { + "message": "Pieteikšanās ar piekūves atslēgu ieslēgta" + }, + "passkeySaved": { + "message": "$NAME$ saglabāta", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Piekļuves atslēga noņemta" + }, + "removePasskey": { + "message": "Noņemt piekļuves atslēgu" + }, + "removePasskeyInfo": { + "message": "Ja visas piekļuves atslēgas ir noņemtas, nebūs iespējams pieteikties jaunās ierīcēs bez galvenās paroles." + }, + "passkeyLimitReachedInfo": { + "message": "Sasniegts piekļuves atslēgu ierobežojums. Jānoņem kāda piekļuves atslēga, lai pievienotu citu." + }, + "tryAgain": { + "message": "Jāmēģina vēlreiz" + }, "createAccount": { "message": "Izveidot kontu" }, @@ -5406,6 +5472,19 @@ "required": { "message": "nepieciešams" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ rakstzīmes", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Ne vairāk kā $MAX$ rakstzīmju", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Ieslēgts" }, + "off": { + "message": "Izslēgts" + }, "members": { "message": "Dalībnieki" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Pievienot Noslēpumu pārvaldnieku" + "subscribeToSecretsManager": { + "message": "Abonēt Noslēpumu pārvaldnieku" }, "addSecretsManagerUpgradeDesc": { "message": "Noslēpumu pārvaldnieks jāpievieno uzlabotajam plānam, lai saglabātu piekļuvi visiem noslēpumiem, kas tika izveidoti iepriekšējā plānā." diff --git a/apps/web/src/locales/ml/messages.json b/apps/web/src/locales/ml/messages.json index c29e0f42fad..e39f2f762d9 100644 --- a/apps/web/src/locales/ml/messages.json +++ b/apps/web/src/locales/ml/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "അക്കൗണ്ട് സൃഷ്ടിക്കുക" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/mr/messages.json b/apps/web/src/locales/mr/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/mr/messages.json +++ b/apps/web/src/locales/mr/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/my/messages.json b/apps/web/src/locales/my/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/my/messages.json +++ b/apps/web/src/locales/my/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/nb/messages.json b/apps/web/src/locales/nb/messages.json index 656d48456cc..9e0b6004513 100644 --- a/apps/web/src/locales/nb/messages.json +++ b/apps/web/src/locales/nb/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Logg på med hovedpassord" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Opprett en konto" }, @@ -5406,6 +5472,19 @@ "required": { "message": "obligatorisk" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Maksimalt $MAX$ tegn", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "På" }, + "off": { + "message": "Off" + }, "members": { "message": "Medlemmer" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/ne/messages.json b/apps/web/src/locales/ne/messages.json index 15621984c0b..4efbf528cf0 100644 --- a/apps/web/src/locales/ne/messages.json +++ b/apps/web/src/locales/ne/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/nl/messages.json b/apps/web/src/locales/nl/messages.json index 09196ea1c8a..66bf305406c 100644 --- a/apps/web/src/locales/nl/messages.json +++ b/apps/web/src/locales/nl/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Inloggen met je hoofdwachtwoord" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Account aanmaken" }, @@ -5406,6 +5472,19 @@ "required": { "message": "vereist" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Maximaal $MAX$ tekens", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Aan" }, + "off": { + "message": "Off" + }, "members": { "message": "Leden" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Secrets Manager toevoegen" + "subscribeToSecretsManager": { + "message": "Abonneren op Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Secrets Manager toevoegen aan je geüpgraded abonnement zodat je toegang houdt tot geheimen die met je vorige abonnement zijn aangemaakt." diff --git a/apps/web/src/locales/nn/messages.json b/apps/web/src/locales/nn/messages.json index da3d7e5a0c3..fa222d061d9 100644 --- a/apps/web/src/locales/nn/messages.json +++ b/apps/web/src/locales/nn/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/or/messages.json b/apps/web/src/locales/or/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/or/messages.json +++ b/apps/web/src/locales/or/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/pl/messages.json b/apps/web/src/locales/pl/messages.json index f6d8e35497a..ee8c1307e09 100644 --- a/apps/web/src/locales/pl/messages.json +++ b/apps/web/src/locales/pl/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Logowanie hasłem głównym" }, + "loginWithPasskey": { + "message": "Zaloguj się za pomocą passkey" + }, + "loginWithPasskeyInfo": { + "message": "Użyj wygenerowanego passkey'a, który automatycznie zaloguje Cię bez hasła. Biometrie, takie jak rozpoznawanie twarzy lub odcisk palca, lub inna metoda zabezpieczeń FIDO2, zweryfikują Twoją tożsamość." + }, + "newPasskey": { + "message": "Nowy passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Dowiedz się więcej o rozwiązaniach bezhasłowych" + }, + "passkeyEnterMasterPassword": { + "message": "Wprowadź hasło główne, aby zmodyfikować ustawienia logowania za pomocą passkey." + }, + "creatingPasskeyLoading": { + "message": "Tworzenie passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Pozostaw to okno otwarte i postępuj zgodnie z instrukcjami z przeglądarki." + }, + "errorCreatingPasskey": { + "message": "Błąd podczas tworzenia passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "Podczas tworzenia passkey'a pojawił się problem." + }, + "passkeySuccessfullyCreated": { + "message": "Pomyślnie utworzono passkey!" + }, + "customName": { + "message": "Niestandardowa nazwa" + }, + "customPasskeyNameInfo": { + "message": "Nazwij swój passkey, aby pomóc Ci go zidentyfikować." + }, + "encryptionNotSupported": { + "message": "Szyfrowanie nie jest obsługiwane" + }, + "loginWithPasskeyEnabled": { + "message": "Logowanie za pomocą passkey zostało włączone" + }, + "passkeySaved": { + "message": "$NAME$ zapisany", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey został usunięty" + }, + "removePasskey": { + "message": "Usuń passkey" + }, + "removePasskeyInfo": { + "message": "Jeśli wszystkie passkey'e zostaną usunięte, nie będziesz mógł zalogować się na nowe urządzenia bez hasła głównego." + }, + "passkeyLimitReachedInfo": { + "message": "Osiągnięto limit passkey'ów. Usuń passkey, aby dodać inny." + }, + "tryAgain": { + "message": "Spróbuj ponownie" + }, "createAccount": { "message": "Utwórz konto" }, @@ -5406,6 +5472,19 @@ "required": { "message": "wymagane" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ znaków", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ znaków maksymalnie", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Wł." }, + "off": { + "message": "Wyłączone" + }, "members": { "message": "Użytkownicy" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Dodaj menedżera sekretów" + "subscribeToSecretsManager": { + "message": "Zasubskrybuj się do menedżera sekretów" }, "addSecretsManagerUpgradeDesc": { "message": "Dodaj menedżera sekretów do zaktualizowanego planu, aby zachować dostęp do wszelkich sekretów utworzonych w poprzednim planie." diff --git a/apps/web/src/locales/pt_BR/messages.json b/apps/web/src/locales/pt_BR/messages.json index c5333d785f0..b35f957ab61 100644 --- a/apps/web/src/locales/pt_BR/messages.json +++ b/apps/web/src/locales/pt_BR/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Entrar com senha mestra" }, + "loginWithPasskey": { + "message": "Iniciar sessão com a chave de acesso" + }, + "loginWithPasskeyInfo": { + "message": "Use uma senha gerada que fará o login automaticamente sem uma senha. Biometrias como reconhecimento facial ou impressão digital, ou outro método de segurança FIDO2 verificarão sua identidade." + }, + "newPasskey": { + "message": "Nova chave de acesso" + }, + "learnMoreAboutPasswordless": { + "message": "Saiba mais sobre acesso sem senha" + }, + "passkeyEnterMasterPassword": { + "message": "Digite sua senha mestra para modificar as configurações de login com chave de acesso." + }, + "creatingPasskeyLoading": { + "message": "Criando chave de acesso..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Mantenha esta janela aberta e siga as instruções do seu navegador." + }, + "errorCreatingPasskey": { + "message": "Erro ao criar chave de acesso" + }, + "errorCreatingPasskeyInfo": { + "message": "Houve um problema ao criar sua chave de acesso." + }, + "passkeySuccessfullyCreated": { + "message": "Chave de acesso criada com sucesso!" + }, + "customName": { + "message": "Nome personalizado" + }, + "customPasskeyNameInfo": { + "message": "Nomeie sua chave de acesso para ajudá-lo a identificá-la." + }, + "encryptionNotSupported": { + "message": "Criptografia não suportada" + }, + "loginWithPasskeyEnabled": { + "message": "Iniciar sessão com a chave de acesso ativada" + }, + "passkeySaved": { + "message": "$NAME$ salvo", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Chave de acesso removida" + }, + "removePasskey": { + "message": "Remover chave de acesso" + }, + "removePasskeyInfo": { + "message": "Se todas as chaves de acesso forem removidas, não será mais possível fazer login em novos dispositivos sem sua senha mestra." + }, + "passkeyLimitReachedInfo": { + "message": "Limite de chaves de acesso atingido. Remova uma chave de acesso para adicionar outra." + }, + "tryAgain": { + "message": "Tente Novamente" + }, "createAccount": { "message": "Criar conta" }, @@ -5406,6 +5472,19 @@ "required": { "message": "obrigatório" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ caracteres no máximo", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "máximo de $MAX$ caracteres", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Ligado" }, + "off": { + "message": "Desligado" + }, "members": { "message": "Membros" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Assine o Gerenciador de Segredos" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/pt_PT/messages.json b/apps/web/src/locales/pt_PT/messages.json index 32c5276b0a1..52db80b2a0c 100644 --- a/apps/web/src/locales/pt_PT/messages.json +++ b/apps/web/src/locales/pt_PT/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Iniciar sessão com a palavra-passe mestra" }, + "loginWithPasskey": { + "message": "Iniciar sessão com a chave de acesso" + }, + "loginWithPasskeyInfo": { + "message": "Utilize uma chave de acesso gerada que lhe permitirá iniciar sessão automaticamente sem uma palavra-passe. A biometria, como o reconhecimento facial ou a impressão digital ou outro método de segurança FIDO2, verificará a sua identidade." + }, + "newPasskey": { + "message": "Nova chave de acesso" + }, + "learnMoreAboutPasswordless": { + "message": "Saiba mais sobre a ausência de palavra-passe" + }, + "passkeyEnterMasterPassword": { + "message": "Introduza a sua palavra-passe mestra para modificar as definições de início de sessão com chave de acesso." + }, + "creatingPasskeyLoading": { + "message": "A criar chave de acesso..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Mantenha esta janela aberta e siga as indicações do seu navegador." + }, + "errorCreatingPasskey": { + "message": "Erro ao criar chave de acesso" + }, + "errorCreatingPasskeyInfo": { + "message": "Houve um problema ao criar a sua chave de acesso." + }, + "passkeySuccessfullyCreated": { + "message": "Chave de acesso criada com sucesso!" + }, + "customName": { + "message": "Nome personalizado" + }, + "customPasskeyNameInfo": { + "message": "Dê um nome à sua chave de acesso para o ajudar a identificá-la." + }, + "encryptionNotSupported": { + "message": "Encriptação não suportada" + }, + "loginWithPasskeyEnabled": { + "message": "Iniciar sessão com a chave de acesso ativada" + }, + "passkeySaved": { + "message": "$NAME$ guardada", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Chave de acesso removida" + }, + "removePasskey": { + "message": "Remover chave de acesso" + }, + "removePasskeyInfo": { + "message": "Se todas as chaves de acesso forem removidas, não será possível iniciar sessão em novos dispositivos sem a sua palavra-passe mestra." + }, + "passkeyLimitReachedInfo": { + "message": "O limite de chaves de acesso foi atingido. Remova uma chave de acesso para adicionar outra." + }, + "tryAgain": { + "message": "Tentar novamente" + }, "createAccount": { "message": "Criar conta" }, @@ -5406,6 +5472,19 @@ "required": { "message": "necessário" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ máximo de caracteres", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Máximo de $MAX$ caracteres", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Ligado" }, + "off": { + "message": "Desligado" + }, "members": { "message": "Membros" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Adicionar Gestor de Segredos" + "subscribeToSecretsManager": { + "message": "Subscrever o Gestor de Segredos" }, "addSecretsManagerUpgradeDesc": { "message": "Adicione o Gestor de Segredos ao seu plano atualizado para manter o acesso a quaisquer segredos criados com o seu plano anterior." diff --git a/apps/web/src/locales/ro/messages.json b/apps/web/src/locales/ro/messages.json index 1df9da3f6bf..9ca18d07767 100644 --- a/apps/web/src/locales/ro/messages.json +++ b/apps/web/src/locales/ro/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Autentificați-vă cu parola principală" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Creare cont" }, @@ -5406,6 +5472,19 @@ "required": { "message": "necesar" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Pornit" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/ru/messages.json b/apps/web/src/locales/ru/messages.json index 705198ac753..b209a6c4d11 100644 --- a/apps/web/src/locales/ru/messages.json +++ b/apps/web/src/locales/ru/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Войти с мастер-паролем" }, + "loginWithPasskey": { + "message": "Войти с ключом доступа" + }, + "loginWithPasskeyInfo": { + "message": "Используйте сгенерированный ключ доступа для автоматической авторизации без пароля. Ваша личность будет подтверждена биометрическими данными (распознаванием лица, отпечатком пальца или другим методом безопасности FIDO2)." + }, + "newPasskey": { + "message": "Новый ключ доступа" + }, + "learnMoreAboutPasswordless": { + "message": "Подробнее о беспарольном режиме" + }, + "passkeyEnterMasterPassword": { + "message": "Для изменения параметров авторизации ключом доступа введите мастер-пароль." + }, + "creatingPasskeyLoading": { + "message": "Создание ключа доступа..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Не закрывайте это окно и следуйте запросам браузера." + }, + "errorCreatingPasskey": { + "message": "Ошибка создания ключа доступа" + }, + "errorCreatingPasskeyInfo": { + "message": "При создании ключа доступа возникла проблема." + }, + "passkeySuccessfullyCreated": { + "message": "Ключ доступа успешно создан!" + }, + "customName": { + "message": "Имя" + }, + "customPasskeyNameInfo": { + "message": "Назовите ключ доступа так, чтобы вы могли его идентифицировать." + }, + "encryptionNotSupported": { + "message": "Шифрование не поддерживается" + }, + "loginWithPasskeyEnabled": { + "message": "Авторизоваться с помощью ключа доступа" + }, + "passkeySaved": { + "message": "$NAME$ сохранен", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Ключ доступа удален" + }, + "removePasskey": { + "message": "Удалить ключ доступа" + }, + "removePasskeyInfo": { + "message": "Если будут удалены все ключи доступа, вы не сможете авторизоваться на новых устройствах без мастер-пароля." + }, + "passkeyLimitReachedInfo": { + "message": "Достигнут предел ключей доступа. Удалите какой-нибудь ключ, чтобы добавить другой." + }, + "tryAgain": { + "message": "Попробуйте снова" + }, "createAccount": { "message": "Создать аккаунт" }, @@ -5406,6 +5472,19 @@ "required": { "message": "обязательно" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ симв. максимум", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Максимум символов $MAX$", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Вкл" }, + "off": { + "message": "Выкл" + }, "members": { "message": "Участники" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Добавить Менеджер секретов" + "subscribeToSecretsManager": { + "message": "Подписаться на Менеджер секретов" }, "addSecretsManagerUpgradeDesc": { "message": "Добавить Менеджер секретов к этому тарифному плану, чтобы сохранить доступ к секретам, созданным ранее." diff --git a/apps/web/src/locales/si/messages.json b/apps/web/src/locales/si/messages.json index efdf9a8c86e..1d8367d6114 100644 --- a/apps/web/src/locales/si/messages.json +++ b/apps/web/src/locales/si/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "ගිණුමක් සාදන්න" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/sk/messages.json b/apps/web/src/locales/sk/messages.json index 08276bda0bc..89d5995a995 100644 --- a/apps/web/src/locales/sk/messages.json +++ b/apps/web/src/locales/sk/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Prihlásenie pomocou hlavného hesla" }, + "loginWithPasskey": { + "message": "Prihlásiť sa s prístupovým kľúčom" + }, + "loginWithPasskeyInfo": { + "message": "Použite vygenerovaný prístupový kľúč, ktorý vás automaticky prihlási bez zadávania hesla. Vaša identita bude overená pomocou biometrie, ako napríklad rozpoznanie tváre alebo odtlačok prsta, alebo pomocou ďalšej FIDO2 bezpečnostnej metódy." + }, + "newPasskey": { + "message": "Nový prístupový kľúč" + }, + "learnMoreAboutPasswordless": { + "message": "Dozvedieť sa viac o automatickom prihlasovaní" + }, + "passkeyEnterMasterPassword": { + "message": "Zadajte vaše hlavné heslo ak chcete zmeniť nastavenia prihlásenia s prístupovým kľúčom." + }, + "creatingPasskeyLoading": { + "message": "Vytvára sa prístupový kľúč..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Ponechajte toto okno otvorené a nasledujte pokyny vášho prehliadača." + }, + "errorCreatingPasskey": { + "message": "Chyba pri vytváraní prístupového kľúča" + }, + "errorCreatingPasskeyInfo": { + "message": "Nastal problém pri vytváraní vášho prístupového kľúča." + }, + "passkeySuccessfullyCreated": { + "message": "Prístupový kľúč bol úspešne vytvorený!" + }, + "customName": { + "message": "Vlastný názov" + }, + "customPasskeyNameInfo": { + "message": "Dajte prístupovému kľúču názov aby ste ho mohli ľahšie identifikovať." + }, + "encryptionNotSupported": { + "message": "Šifrovanie nie je podporované" + }, + "loginWithPasskeyEnabled": { + "message": "Prihlásenie s prístupovým kľúčom zapnuté" + }, + "passkeySaved": { + "message": "$NAME$ uložený", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Prístupový kľúč bol odstránený" + }, + "removePasskey": { + "message": "Odstrániť prístupový kľúč" + }, + "removePasskeyInfo": { + "message": "Ak odstránite všetky prístupové kľúče, bez hlavného hesla sa nebudete môcť prihlásiť na nových zariadeniach." + }, + "passkeyLimitReachedInfo": { + "message": "Dosiahnutý limit počtu prístupových kľúčov. Odstráňte prístupový kľuč, ak chcete pridať ďalší." + }, + "tryAgain": { + "message": "Skúsiť znova" + }, "createAccount": { "message": "Vytvoriť účet" }, @@ -5406,6 +5472,19 @@ "required": { "message": "povinné" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ maximálneho počtu znakov", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "maximálne $MAX$ znakov", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Zapnuté" }, + "off": { + "message": "Vypnuté" + }, "members": { "message": "Členovia" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Pridať Secrets Manager" + "subscribeToSecretsManager": { + "message": "Predplatiť Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Do aktualizovaného plánu pridajte Secret Manager, aby ste si zachovali prístup k všetkým tajomstvám vytvoreným v predchádzajúcom pláne." diff --git a/apps/web/src/locales/sl/messages.json b/apps/web/src/locales/sl/messages.json index 0f5ff850696..d082c126e7e 100644 --- a/apps/web/src/locales/sl/messages.json +++ b/apps/web/src/locales/sl/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Prijava z glavnim geslom" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Ustvarite si račun" }, @@ -5406,6 +5472,19 @@ "required": { "message": "obvezno" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/sr/messages.json b/apps/web/src/locales/sr/messages.json index a1844d86fc8..2fad2224356 100644 --- a/apps/web/src/locales/sr/messages.json +++ b/apps/web/src/locales/sr/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Пријавите се са главном лозинком" }, + "loginWithPasskey": { + "message": "Пријави се са passkey" + }, + "loginWithPasskeyInfo": { + "message": "Користите генерисан passkey који ће вас аутоматски пријавити без лозинке. Биометрија, као што је препознавање лица или отисак прста, или неки други FIDO2 метод за проверу идентитета." + }, + "newPasskey": { + "message": "Нов passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Сазнајте више о пријављивању без лозинке" + }, + "passkeyEnterMasterPassword": { + "message": "Унесите главну лозинку да бисте изменили подешавања пријављивања са passkey." + }, + "creatingPasskeyLoading": { + "message": "Креација passkey-а..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Држите овај прозор отворен и пратите упутства из прегледача." + }, + "errorCreatingPasskey": { + "message": "Грешки у креацији passkey-а" + }, + "errorCreatingPasskeyInfo": { + "message": "Дошло је до проблема приликом креирања вашег passkey-а." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey успешно креиран!" + }, + "customName": { + "message": "Прилагоди име" + }, + "customPasskeyNameInfo": { + "message": "Именујте Ваш passkey за лакшу идентификацију." + }, + "encryptionNotSupported": { + "message": "Шифровање није подржано" + }, + "loginWithPasskeyEnabled": { + "message": "Пријављивање са passkey је упаљено" + }, + "passkeySaved": { + "message": "„$NAME$“ сачувано", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey уклоњен" + }, + "removePasskey": { + "message": "Уклонити passkey" + }, + "removePasskeyInfo": { + "message": "Ако су сви passkey уклоњени, нећете моћи да се пријавите на нове уређаје без ваше главне лозинке." + }, + "passkeyLimitReachedInfo": { + "message": "Достугнут лимит Passkey-а. Уклонити један да би додали други." + }, + "tryAgain": { + "message": "Покушај поново" + }, "createAccount": { "message": "Креирај налог" }, @@ -5406,6 +5472,19 @@ "required": { "message": "обавезно" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$ од макс $MAX$ карактера", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Максимум $MAX$ карактера", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Укључено" }, + "off": { + "message": "Искључено" + }, "members": { "message": "Чланови" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Додати Менаџер Тајни" + "subscribeToSecretsManager": { + "message": "Пријави се на Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." @@ -7179,9 +7261,9 @@ "message": "Већ имате налог?" }, "customBillingStart": { - "message": "Custom billing is not reflected. Visit the " + "message": "Прилагођени обрачун се не одражава. Посетите " }, "customBillingEnd": { - "message": " page for latest invoicing." + "message": " страницу за најновије фактуре." } } diff --git a/apps/web/src/locales/sr_CS/messages.json b/apps/web/src/locales/sr_CS/messages.json index 78ab8fa1c70..47e86ccb131 100644 --- a/apps/web/src/locales/sr_CS/messages.json +++ b/apps/web/src/locales/sr_CS/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Napravi Nalog" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/sv/messages.json b/apps/web/src/locales/sv/messages.json index c5880efe924..9940c818b90 100644 --- a/apps/web/src/locales/sv/messages.json +++ b/apps/web/src/locales/sv/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Logga in med huvudlösenord" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Anpassat namn" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Försök igen" + }, "createAccount": { "message": "Skapa konto" }, @@ -1261,7 +1327,7 @@ "message": "Data har importerats till ditt valv" }, "importSuccessNumberOfItems": { - "message": "A total of $AMOUNT$ items were imported.", + "message": "Sammanlagt $AMOUNT$ objekt importerades.", "placeholders": { "amount": { "content": "$1", @@ -5406,6 +5472,19 @@ "required": { "message": "obligatoriskt" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Max $MAX$ tecken", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "På" }, + "off": { + "message": "Off" + }, "members": { "message": "Medlemmar" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Lägg till Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/te/messages.json b/apps/web/src/locales/te/messages.json index 7d1a4bebebd..28b3af6c2d5 100644 --- a/apps/web/src/locales/te/messages.json +++ b/apps/web/src/locales/te/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Log in with master password" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Create account" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/th/messages.json b/apps/web/src/locales/th/messages.json index b429ed92dae..f2b7fce7408 100644 --- a/apps/web/src/locales/th/messages.json +++ b/apps/web/src/locales/th/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "เข้าสู่ระบบด้วยรหัสผ่านหลัก" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "สร้างบัญชีผู้ใช้" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/tr/messages.json b/apps/web/src/locales/tr/messages.json index 627eec657cb..7561db89d67 100644 --- a/apps/web/src/locales/tr/messages.json +++ b/apps/web/src/locales/tr/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Ana parola ile giriş yap" }, + "loginWithPasskey": { + "message": "Şifreyle giriş yapın" + }, + "loginWithPasskeyInfo": { + "message": "Parola olmadan otomatik olarak oturum açmanızı sağlayacak oluşturulmuş bir geçiş anahtarı kullanın. Yüz tanıma veya parmak izi gibi biyometri veya başka bir FIDO2 güvenlik yöntemi kimliğinizi doğrulayacaktır." + }, + "newPasskey": { + "message": "Yeni şifre" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Şifre anahtarıyla oturum açma ayarlarını değiştirmek için ana şifrenizi girin." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Bu pencereyi açık tutun ve tarayıcınızdan gelen talimatları izleyin." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "Şifreniz oluşturulurken bir sorun oluştu." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ kaydedildi", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Hesap aç" }, @@ -5406,6 +5472,19 @@ "required": { "message": "zorunlu" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "en fazla $MAX$ karakter", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Açık" }, + "off": { + "message": "Kapalı" + }, "members": { "message": "Üyeler" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Secrets Manager'a abone olun" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/uk/messages.json b/apps/web/src/locales/uk/messages.json index 29111deeb91..878063b9bee 100644 --- a/apps/web/src/locales/uk/messages.json +++ b/apps/web/src/locales/uk/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Увійти з головним паролем" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Створити обліковий запис" }, @@ -5406,6 +5472,19 @@ "required": { "message": "обов'язково" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "Максимум $MAX$ символів", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "Увімкнено" }, + "off": { + "message": "Off" + }, "members": { "message": "Учасники" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Додати менеджер секретів" + "subscribeToSecretsManager": { + "message": "Передплатити менеджер секретів" }, "addSecretsManagerUpgradeDesc": { "message": "Додайте менеджер секретів до свого оновленого плану, щоб підтримувати доступ до будь-яких секретів, створених у вашому минулому плані." diff --git a/apps/web/src/locales/vi/messages.json b/apps/web/src/locales/vi/messages.json index 1483e23a2be..c8d4017e30d 100644 --- a/apps/web/src/locales/vi/messages.json +++ b/apps/web/src/locales/vi/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "Đăng nhập bằng mật khẩu chính" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "Tạo tài khoản" }, @@ -5406,6 +5472,19 @@ "required": { "message": "required" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ ký tự tối đa", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "On" }, + "off": { + "message": "Off" + }, "members": { "message": "Members" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "Add Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "Add Secrets Manager to your upgraded plan to maintain access to any secrets created with your previous plan." diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 7f3fc7f09fa..daee6752f5f 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "使用主密码登录" }, + "loginWithPasskey": { + "message": "使用通行密钥登录" + }, + "loginWithPasskeyInfo": { + "message": "使用已生成的通行密钥,无需密码即可自动登录。生物识别技术(例如面部识别或指纹)或其他 FIDO2 安全方法将用于验证您的身份。" + }, + "newPasskey": { + "message": "新增通行密钥" + }, + "learnMoreAboutPasswordless": { + "message": "了解更多关于无密码登录的信息" + }, + "passkeyEnterMasterPassword": { + "message": "输入您的主密码以修改通行密钥登录设置。" + }, + "creatingPasskeyLoading": { + "message": "正在创建通行密钥..." + }, + "creatingPasskeyLoadingInfo": { + "message": "保持此窗口打开然后按照浏览器的提示操作。" + }, + "errorCreatingPasskey": { + "message": "创建通行密钥时出错" + }, + "errorCreatingPasskeyInfo": { + "message": "创建通行密钥时出现问题。" + }, + "passkeySuccessfullyCreated": { + "message": "通行密钥创建成功!" + }, + "customName": { + "message": "自定义名称" + }, + "customPasskeyNameInfo": { + "message": "为您的通行密钥命名以帮助您识别它。" + }, + "encryptionNotSupported": { + "message": "不支持加密" + }, + "loginWithPasskeyEnabled": { + "message": "已启用通行密钥登录" + }, + "passkeySaved": { + "message": "$NAME$ 已保存", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "通行密钥已移除" + }, + "removePasskey": { + "message": "移除通行密钥" + }, + "removePasskeyInfo": { + "message": "如果移除了所有通行密钥,没有主密码您将无法登录新的设备。" + }, + "passkeyLimitReachedInfo": { + "message": "通行密钥已达上限。移除一个通行密钥以添加另一个。" + }, + "tryAgain": { + "message": "再试一次" + }, "createAccount": { "message": "创建账户" }, @@ -5406,6 +5472,19 @@ "required": { "message": "必填" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$ / 最大字符数 $MAX$", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "最多 $MAX$ 个字符", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "开启" }, + "off": { + "message": "Off" + }, "members": { "message": "成员" }, @@ -5913,7 +5995,7 @@ "description": "A machine user which can be used to automate processes and access secrets in the system." }, "serviceAccounts": { - "message": "服务帐户", + "message": "服务账户", "description": "The title for the section that deals with service accounts." }, "secrets": { @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "添加机密管理器" + "subscribeToSecretsManager": { + "message": "订阅机密管理器" }, "addSecretsManagerUpgradeDesc": { "message": "将机密管理器添加到升级后的计划中,以保留对使用之前的计划创建的所有机密的访问权限。" diff --git a/apps/web/src/locales/zh_TW/messages.json b/apps/web/src/locales/zh_TW/messages.json index eba26af5767..cb040a728dc 100644 --- a/apps/web/src/locales/zh_TW/messages.json +++ b/apps/web/src/locales/zh_TW/messages.json @@ -611,6 +611,72 @@ "loginWithMasterPassword": { "message": "使用主密碼登入" }, + "loginWithPasskey": { + "message": "Log in with passkey" + }, + "loginWithPasskeyInfo": { + "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." + }, + "newPasskey": { + "message": "New passkey" + }, + "learnMoreAboutPasswordless": { + "message": "Learn more about passwordless" + }, + "passkeyEnterMasterPassword": { + "message": "Enter your master password to modify log in with passkey settings." + }, + "creatingPasskeyLoading": { + "message": "Creating passkey..." + }, + "creatingPasskeyLoadingInfo": { + "message": "Keep this window open and follow prompts from your browser." + }, + "errorCreatingPasskey": { + "message": "Error creating passkey" + }, + "errorCreatingPasskeyInfo": { + "message": "There was a problem creating your passkey." + }, + "passkeySuccessfullyCreated": { + "message": "Passkey successfully created!" + }, + "customName": { + "message": "Custom name" + }, + "customPasskeyNameInfo": { + "message": "Name your passkey to help you identify it." + }, + "encryptionNotSupported": { + "message": "Encryption not supported" + }, + "loginWithPasskeyEnabled": { + "message": "Log in with passkey turned on" + }, + "passkeySaved": { + "message": "$NAME$ saved", + "placeholders": { + "name": { + "content": "$1", + "example": "Personal yubikey" + } + } + }, + "passkeyRemoved": { + "message": "Passkey removed" + }, + "removePasskey": { + "message": "Remove passkey" + }, + "removePasskeyInfo": { + "message": "If all passkeys are removed, you will be unable to log into new devices without your master password." + }, + "passkeyLimitReachedInfo": { + "message": "Passkey limit reached. Remove a passkey to add another." + }, + "tryAgain": { + "message": "Try again" + }, "createAccount": { "message": "建立帳戶" }, @@ -5406,6 +5472,19 @@ "required": { "message": "必填" }, + "charactersCurrentAndMaximum": { + "message": "$CURRENT$/$MAX$ character maximum", + "placeholders": { + "current": { + "content": "$1", + "example": "0" + }, + "max": { + "content": "$2", + "example": "100" + } + } + }, "characterMaximum": { "message": "$MAX$ character maximum", "placeholders": { @@ -5754,6 +5833,9 @@ "on": { "message": "開啟" }, + "off": { + "message": "Off" + }, "members": { "message": "成員" }, @@ -7096,8 +7178,8 @@ } } }, - "addSecretsManager": { - "message": "新增 Secrets Manager" + "subscribeToSecretsManager": { + "message": "Subscribe to Secrets Manager" }, "addSecretsManagerUpgradeDesc": { "message": "將 Secrets Manager 加入您的升級方案,來記錄維持存取於先前方案中保存的秘密資訊。" From 67fa1e06d05cb395c2121cbdb6f53012283cdb81 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 13 Oct 2023 10:41:47 +0200 Subject: [PATCH 09/85] [PM-4195] Lastpass access lib refactors (#6566) * Fix setUserTypeContext by adding missing return * Throw new Error instead of just string * Move enums and models into separate folders * Move UI classes into separate folder * Move FederatedUserContext import * Move services into a separate folder * Add barrel file for lastpass access lib * Fix build by updating imports after move --------- Co-authored-by: Daniel James Smith --- .../lastpass/access/enums/duo-factor.ts | 6 +++ .../lastpass/access/enums/duo-status.ts | 5 +++ .../lastpass/access/enums/idp-provider.ts | 8 ++++ .../importers/lastpass/access/enums/index.ts | 6 +++ .../access/enums/lastpass-login-type.ts | 5 +++ .../lastpass/access/{ => enums}/otp-method.ts | 0 .../lastpass/access/{ => enums}/platform.ts | 0 .../src/importers/lastpass/access/index.ts | 1 + .../lastpass/access/{ => models}/account.ts | 0 .../lastpass/access/{ => models}/chunk.ts | 0 .../access/{ => models}/client-info.ts | 2 +- .../{ => models}/federated-user-context.ts | 0 .../importers/lastpass/access/models/index.ts | 10 +++++ .../access/{ => models}/oob-result.ts | 0 .../access/{ => models}/otp-result.ts | 0 .../access/{ => models}/parser-options.ts | 0 .../lastpass/access/{ => models}/session.ts | 2 +- .../access/{ => models}/shared-folder.ts | 0 .../access/{ => models}/user-type-context.ts | 23 +++------- .../access/{ => services}/binary-reader.ts | 6 +-- .../lastpass/access/{ => services}/client.ts | 42 ++++++++++-------- .../access/{ => services}/crypto-utils.ts | 4 +- .../lastpass/access/services/index.ts | 5 +++ .../lastpass/access/{ => services}/parser.ts | 8 ++-- .../access/{ => services}/rest-client.ts | 0 .../lastpass/access/{ => ui}/duo-ui.ts | 15 +------ .../src/importers/lastpass/access/ui/index.ts | 2 + .../importers/lastpass/access/{ => ui}/ui.ts | 4 +- .../src/importers/lastpass/access/vault.ts | 43 ++++++++++--------- 29 files changed, 112 insertions(+), 85 deletions(-) create mode 100644 libs/importer/src/importers/lastpass/access/enums/duo-factor.ts create mode 100644 libs/importer/src/importers/lastpass/access/enums/duo-status.ts create mode 100644 libs/importer/src/importers/lastpass/access/enums/idp-provider.ts create mode 100644 libs/importer/src/importers/lastpass/access/enums/index.ts create mode 100644 libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts rename libs/importer/src/importers/lastpass/access/{ => enums}/otp-method.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => enums}/platform.ts (100%) create mode 100644 libs/importer/src/importers/lastpass/access/index.ts rename libs/importer/src/importers/lastpass/access/{ => models}/account.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => models}/chunk.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => models}/client-info.ts (69%) rename libs/importer/src/importers/lastpass/access/{ => models}/federated-user-context.ts (100%) create mode 100644 libs/importer/src/importers/lastpass/access/models/index.ts rename libs/importer/src/importers/lastpass/access/{ => models}/oob-result.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => models}/otp-result.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => models}/parser-options.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => models}/session.ts (78%) rename libs/importer/src/importers/lastpass/access/{ => models}/shared-folder.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => models}/user-type-context.ts (63%) rename libs/importer/src/importers/lastpass/access/{ => services}/binary-reader.ts (91%) rename libs/importer/src/importers/lastpass/access/{ => services}/client.ts (94%) rename libs/importer/src/importers/lastpass/access/{ => services}/crypto-utils.ts (96%) create mode 100644 libs/importer/src/importers/lastpass/access/services/index.ts rename libs/importer/src/importers/lastpass/access/{ => services}/parser.ts (97%) rename libs/importer/src/importers/lastpass/access/{ => services}/rest-client.ts (100%) rename libs/importer/src/importers/lastpass/access/{ => ui}/duo-ui.ts (81%) create mode 100644 libs/importer/src/importers/lastpass/access/ui/index.ts rename libs/importer/src/importers/lastpass/access/{ => ui}/ui.ts (93%) diff --git a/libs/importer/src/importers/lastpass/access/enums/duo-factor.ts b/libs/importer/src/importers/lastpass/access/enums/duo-factor.ts new file mode 100644 index 00000000000..aa65583935e --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/enums/duo-factor.ts @@ -0,0 +1,6 @@ +export enum DuoFactor { + Push, + Call, + Passcode, + SendPasscodesBySms, +} diff --git a/libs/importer/src/importers/lastpass/access/enums/duo-status.ts b/libs/importer/src/importers/lastpass/access/enums/duo-status.ts new file mode 100644 index 00000000000..6397db5dc91 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/enums/duo-status.ts @@ -0,0 +1,5 @@ +export enum DuoStatus { + Success, + Error, + Info, +} diff --git a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts new file mode 100644 index 00000000000..32e74c36ee1 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts @@ -0,0 +1,8 @@ +export enum IdpProvider { + Azure = 0, + OktaAuthServer = 1, + OktaNoAuthServer = 2, + Google = 3, + PingOne = 4, + OneLogin = 5, +} diff --git a/libs/importer/src/importers/lastpass/access/enums/index.ts b/libs/importer/src/importers/lastpass/access/enums/index.ts new file mode 100644 index 00000000000..0059030e0aa --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/enums/index.ts @@ -0,0 +1,6 @@ +export { DuoFactor } from "./duo-factor"; +export { DuoStatus } from "./duo-status"; +export { IdpProvider } from "./idp-provider"; +export { LastpassLoginType } from "./lastpass-login-type"; +export { OtpMethod } from "./otp-method"; +export { Platform } from "./platform"; diff --git a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts new file mode 100644 index 00000000000..611dd0b6dab --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts @@ -0,0 +1,5 @@ +export enum LastpassLoginType { + MasterPassword = 0, + // Not sure what Types 1 and 2 are? + Federated = 3, +} diff --git a/libs/importer/src/importers/lastpass/access/otp-method.ts b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/otp-method.ts rename to libs/importer/src/importers/lastpass/access/enums/otp-method.ts diff --git a/libs/importer/src/importers/lastpass/access/platform.ts b/libs/importer/src/importers/lastpass/access/enums/platform.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/platform.ts rename to libs/importer/src/importers/lastpass/access/enums/platform.ts diff --git a/libs/importer/src/importers/lastpass/access/index.ts b/libs/importer/src/importers/lastpass/access/index.ts new file mode 100644 index 00000000000..a124a44b315 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/index.ts @@ -0,0 +1 @@ +export { Vault } from "./vault"; diff --git a/libs/importer/src/importers/lastpass/access/account.ts b/libs/importer/src/importers/lastpass/access/models/account.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/account.ts rename to libs/importer/src/importers/lastpass/access/models/account.ts diff --git a/libs/importer/src/importers/lastpass/access/chunk.ts b/libs/importer/src/importers/lastpass/access/models/chunk.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/chunk.ts rename to libs/importer/src/importers/lastpass/access/models/chunk.ts diff --git a/libs/importer/src/importers/lastpass/access/client-info.ts b/libs/importer/src/importers/lastpass/access/models/client-info.ts similarity index 69% rename from libs/importer/src/importers/lastpass/access/client-info.ts rename to libs/importer/src/importers/lastpass/access/models/client-info.ts index fbe13d57d65..275cdc00d3f 100644 --- a/libs/importer/src/importers/lastpass/access/client-info.ts +++ b/libs/importer/src/importers/lastpass/access/models/client-info.ts @@ -1,4 +1,4 @@ -import { Platform } from "./platform"; +import { Platform } from "../enums"; export class ClientInfo { platform: Platform; diff --git a/libs/importer/src/importers/lastpass/access/federated-user-context.ts b/libs/importer/src/importers/lastpass/access/models/federated-user-context.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/federated-user-context.ts rename to libs/importer/src/importers/lastpass/access/models/federated-user-context.ts diff --git a/libs/importer/src/importers/lastpass/access/models/index.ts b/libs/importer/src/importers/lastpass/access/models/index.ts new file mode 100644 index 00000000000..a0c6121a354 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/models/index.ts @@ -0,0 +1,10 @@ +export { Account } from "./account"; +export { Chunk } from "./chunk"; +export { ClientInfo } from "./client-info"; +export { FederatedUserContext } from "./federated-user-context"; +export { OobResult } from "./oob-result"; +export { OtpResult } from "./otp-result"; +export { ParserOptions } from "./parser-options"; +export { Session } from "./session"; +export { SharedFolder } from "./shared-folder"; +export { UserTypeContext } from "./user-type-context"; diff --git a/libs/importer/src/importers/lastpass/access/oob-result.ts b/libs/importer/src/importers/lastpass/access/models/oob-result.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/oob-result.ts rename to libs/importer/src/importers/lastpass/access/models/oob-result.ts diff --git a/libs/importer/src/importers/lastpass/access/otp-result.ts b/libs/importer/src/importers/lastpass/access/models/otp-result.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/otp-result.ts rename to libs/importer/src/importers/lastpass/access/models/otp-result.ts diff --git a/libs/importer/src/importers/lastpass/access/parser-options.ts b/libs/importer/src/importers/lastpass/access/models/parser-options.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/parser-options.ts rename to libs/importer/src/importers/lastpass/access/models/parser-options.ts diff --git a/libs/importer/src/importers/lastpass/access/session.ts b/libs/importer/src/importers/lastpass/access/models/session.ts similarity index 78% rename from libs/importer/src/importers/lastpass/access/session.ts rename to libs/importer/src/importers/lastpass/access/models/session.ts index 4c712872632..f691968a7a7 100644 --- a/libs/importer/src/importers/lastpass/access/session.ts +++ b/libs/importer/src/importers/lastpass/access/models/session.ts @@ -1,4 +1,4 @@ -import { Platform } from "./platform"; +import { Platform } from "../enums"; export class Session { id: string; diff --git a/libs/importer/src/importers/lastpass/access/shared-folder.ts b/libs/importer/src/importers/lastpass/access/models/shared-folder.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/shared-folder.ts rename to libs/importer/src/importers/lastpass/access/models/shared-folder.ts diff --git a/libs/importer/src/importers/lastpass/access/user-type-context.ts b/libs/importer/src/importers/lastpass/access/models/user-type-context.ts similarity index 63% rename from libs/importer/src/importers/lastpass/access/user-type-context.ts rename to libs/importer/src/importers/lastpass/access/models/user-type-context.ts index f2629d59516..9d849281c2d 100644 --- a/libs/importer/src/importers/lastpass/access/user-type-context.ts +++ b/libs/importer/src/importers/lastpass/access/models/user-type-context.ts @@ -1,17 +1,19 @@ +import { IdpProvider, LastpassLoginType } from "../enums"; + export class UserTypeContext { - type: Type; + type: LastpassLoginType; IdentityProviderGUID: string; IdentityProviderURL: string; OpenIDConnectAuthority: string; OpenIDConnectClientId: string; CompanyId: number; - Provider: Provider; + Provider: IdpProvider; PkceEnabled: boolean; IsPasswordlessEnabled: boolean; isFederated(): boolean { return ( - this.type === Type.Federated && + this.type === LastpassLoginType.Federated && this.hasValue(this.IdentityProviderURL) && this.hasValue(this.OpenIDConnectAuthority) && this.hasValue(this.OpenIDConnectClientId) @@ -22,18 +24,3 @@ export class UserTypeContext { return str != null && str.trim() !== ""; } } - -export enum Provider { - Azure = 0, - OktaAuthServer = 1, - OktaNoAuthServer = 2, - Google = 3, - PingOne = 4, - OneLogin = 5, -} - -export enum Type { - MasterPassword = 0, - // Not sure what Types 1 and 2 are? - Federated = 3, -} diff --git a/libs/importer/src/importers/lastpass/access/binary-reader.ts b/libs/importer/src/importers/lastpass/access/services/binary-reader.ts similarity index 91% rename from libs/importer/src/importers/lastpass/access/binary-reader.ts rename to libs/importer/src/importers/lastpass/access/services/binary-reader.ts index 706afbd9e9b..e7a434e957f 100644 --- a/libs/importer/src/importers/lastpass/access/binary-reader.ts +++ b/libs/importer/src/importers/lastpass/access/services/binary-reader.ts @@ -12,7 +12,7 @@ export class BinaryReader { readBytes(count: number): Uint8Array { if (this.position + count > this.arr.length) { - throw "End of array reached"; + throw new Error("End of array reached"); } const slice = this.arr.subarray(this.position, this.position + count); this.position += count; @@ -62,10 +62,10 @@ export class BinaryReader { seekFromCurrentPosition(offset: number) { const newPosition = this.position + offset; if (newPosition < 0) { - throw "Position cannot be negative"; + throw new Error("Position cannot be negative"); } if (newPosition > this.arr.length) { - throw "Array not large enough to seek to this position"; + throw new Error("Array not large enough to seek to this position"); } this.position = newPosition; } diff --git a/libs/importer/src/importers/lastpass/access/client.ts b/libs/importer/src/importers/lastpass/access/services/client.ts similarity index 94% rename from libs/importer/src/importers/lastpass/access/client.ts rename to libs/importer/src/importers/lastpass/access/services/client.ts index 0a3c8fefe52..2d8b503f01d 100644 --- a/libs/importer/src/importers/lastpass/access/client.ts +++ b/libs/importer/src/importers/lastpass/access/services/client.ts @@ -1,21 +1,23 @@ import { HttpStatusCode } from "@bitwarden/common/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { Account } from "./account"; +import { OtpMethod, Platform } from "../enums"; +import { + Account, + Chunk, + ClientInfo, + OobResult, + OtpResult, + ParserOptions, + Session, + SharedFolder, +} from "../models"; +import { Ui } from "../ui"; + import { BinaryReader } from "./binary-reader"; -import { Chunk } from "./chunk"; -import { ClientInfo } from "./client-info"; import { CryptoUtils } from "./crypto-utils"; -import { OobResult } from "./oob-result"; -import { OtpMethod } from "./otp-method"; -import { OtpResult } from "./otp-result"; import { Parser } from "./parser"; -import { ParserOptions } from "./parser-options"; -import { Platform } from "./platform"; import { RestClient } from "./rest-client"; -import { Session } from "./session"; -import { SharedFolder } from "./shared-folder"; -import { Ui } from "./ui"; const PlatformToUserAgent = new Map([ [Platform.Desktop, "cli"], @@ -68,7 +70,7 @@ export class Client { const reader = new BinaryReader(blob); const chunks = this.parser.extractChunks(reader); if (!this.isComplete(chunks)) { - throw "Blob is truncated or corrupted"; + throw new Error("Blob is truncated or corrupted"); } return await this.parseAccounts(chunks, encryptionKey, privateKey, options); } @@ -236,11 +238,11 @@ export class Client { passcode = ui.provideYubikeyPasscode(); break; default: - throw "Invalid OTP method"; + throw new Error("Invalid OTP method"); } if (passcode == OtpResult.cancel) { - throw "Second factor step is canceled by the user"; + throw new Error("Second factor step is canceled by the user"); } const response = await this.performSingleLoginRequest( @@ -273,7 +275,7 @@ export class Client { ): Promise { const answer = this.approveOob(username, parameters, ui, rest); if (answer == OobResult.cancel) { - throw "Out of band step is canceled by the user"; + throw new Error("Out of band step is canceled by the user"); } const extraParameters = new Map(); @@ -319,7 +321,7 @@ export class Client { private approveOob(username: string, parameters: Map, ui: Ui, rest: RestClient) { const method = parameters.get("outofbandtype"); if (method == null) { - throw "Out of band method is not specified"; + throw new Error("Out of band method is not specified"); } switch (method) { case "lastpassauth": @@ -329,7 +331,7 @@ export class Client { case "salesforcehash": return ui.approveSalesforceAuth(); default: - throw "Out of band method " + method + " is not supported"; + throw new Error("Out of band method " + method + " is not supported"); } } @@ -410,7 +412,7 @@ export class Client { if (attr != null) { return attr; } - throw "Unknown response schema: attribute " + name + " is missing"; + throw new Error("Unknown response schema: attribute " + name + " is missing"); } private getOptionalErrorAttribute(response: Document, name: string): string { @@ -505,7 +507,9 @@ export class Client { private makeError(response: Response) { // TODO: error parsing - throw "HTTP request to " + response.url + " failed with status " + response.status + "."; + throw new Error( + "HTTP request to " + response.url + " failed with status " + response.status + "." + ); } private makeLoginError(response: Document): string { diff --git a/libs/importer/src/importers/lastpass/access/crypto-utils.ts b/libs/importer/src/importers/lastpass/access/services/crypto-utils.ts similarity index 96% rename from libs/importer/src/importers/lastpass/access/crypto-utils.ts rename to libs/importer/src/importers/lastpass/access/services/crypto-utils.ts index c8d9f8a168b..4de046f2aa3 100644 --- a/libs/importer/src/importers/lastpass/access/crypto-utils.ts +++ b/libs/importer/src/importers/lastpass/access/services/crypto-utils.ts @@ -6,7 +6,7 @@ export class CryptoUtils { async deriveKey(username: string, password: string, iterationCount: number) { if (iterationCount < 0) { - throw "Iteration count should be positive"; + throw new Error("Iteration count should be positive"); } if (iterationCount == 1) { return await this.cryptoFunctionService.hash(username + password, "sha256"); @@ -27,7 +27,7 @@ export class CryptoUtils { ExclusiveOr(arr1: Uint8Array, arr2: Uint8Array) { if (arr1.length !== arr2.length) { - throw "Arrays must be the same length."; + throw new Error("Arrays must be the same length."); } const result = new Uint8Array(arr1.length); for (let i = 0; i < arr1.length; i++) { diff --git a/libs/importer/src/importers/lastpass/access/services/index.ts b/libs/importer/src/importers/lastpass/access/services/index.ts new file mode 100644 index 00000000000..2610efdb694 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/services/index.ts @@ -0,0 +1,5 @@ +export { BinaryReader } from "./binary-reader"; +export { Client } from "./client"; +export { CryptoUtils } from "./crypto-utils"; +export { Parser } from "./parser"; +export { RestClient } from "./rest-client"; diff --git a/libs/importer/src/importers/lastpass/access/parser.ts b/libs/importer/src/importers/lastpass/access/services/parser.ts similarity index 97% rename from libs/importer/src/importers/lastpass/access/parser.ts rename to libs/importer/src/importers/lastpass/access/services/parser.ts index fc4b3b4a49a..3d64490be12 100644 --- a/libs/importer/src/importers/lastpass/access/parser.ts +++ b/libs/importer/src/importers/lastpass/access/services/parser.ts @@ -1,12 +1,10 @@ import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { Account } from "./account"; +import { Account, Chunk, ParserOptions, SharedFolder } from "../models"; + import { BinaryReader } from "./binary-reader"; -import { Chunk } from "./chunk"; import { CryptoUtils } from "./crypto-utils"; -import { ParserOptions } from "./parser-options"; -import { SharedFolder } from "./shared-folder"; const AllowedSecureNoteTypes = new Set([ "Server", @@ -285,7 +283,7 @@ export class Parser { const header = "LastPassPrivateKey<"; const footer = ">LastPassPrivateKey"; if (!decrypted.startsWith(header) || !decrypted.endsWith(footer)) { - throw "Failed to decrypt private key"; + throw new Error("Failed to decrypt private key"); } const parsedKey = decrypted.substring(header.length, decrypted.length - footer.length); diff --git a/libs/importer/src/importers/lastpass/access/rest-client.ts b/libs/importer/src/importers/lastpass/access/services/rest-client.ts similarity index 100% rename from libs/importer/src/importers/lastpass/access/rest-client.ts rename to libs/importer/src/importers/lastpass/access/services/rest-client.ts diff --git a/libs/importer/src/importers/lastpass/access/duo-ui.ts b/libs/importer/src/importers/lastpass/access/ui/duo-ui.ts similarity index 81% rename from libs/importer/src/importers/lastpass/access/duo-ui.ts rename to libs/importer/src/importers/lastpass/access/ui/duo-ui.ts index 61b52d2582a..60afd0ad9df 100644 --- a/libs/importer/src/importers/lastpass/access/duo-ui.ts +++ b/libs/importer/src/importers/lastpass/access/ui/duo-ui.ts @@ -1,3 +1,5 @@ +import { DuoFactor, DuoStatus } from "../enums"; + // Adds Duo functionality to the module-specific Ui class. export abstract class DuoUi { // To cancel return null @@ -8,19 +10,6 @@ export abstract class DuoUi { updateDuoStatus: (status: DuoStatus, text: string) => void; } -export enum DuoFactor { - Push, - Call, - Passcode, - SendPasscodesBySms, -} - -export enum DuoStatus { - Success, - Error, - Info, -} - export interface DuoChoice { device: DuoDevice; factor: DuoFactor; diff --git a/libs/importer/src/importers/lastpass/access/ui/index.ts b/libs/importer/src/importers/lastpass/access/ui/index.ts new file mode 100644 index 00000000000..e4edc3b6b48 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/ui/index.ts @@ -0,0 +1,2 @@ +export { DuoUi, DuoChoice, DuoDevice } from "./duo-ui"; +export { Ui } from "./ui"; diff --git a/libs/importer/src/importers/lastpass/access/ui.ts b/libs/importer/src/importers/lastpass/access/ui/ui.ts similarity index 93% rename from libs/importer/src/importers/lastpass/access/ui.ts rename to libs/importer/src/importers/lastpass/access/ui/ui.ts index fad86596187..2338e8a291e 100644 --- a/libs/importer/src/importers/lastpass/access/ui.ts +++ b/libs/importer/src/importers/lastpass/access/ui/ui.ts @@ -1,6 +1,6 @@ +import { OobResult, OtpResult } from "../models"; + import { DuoUi } from "./duo-ui"; -import { OobResult } from "./oob-result"; -import { OtpResult } from "./otp-result"; export abstract class Ui extends DuoUi { // To cancel return OtpResult.Cancel, otherwise only valid data is expected. diff --git a/libs/importer/src/importers/lastpass/access/vault.ts b/libs/importer/src/importers/lastpass/access/vault.ts index 157965804c2..a461239eea8 100644 --- a/libs/importer/src/importers/lastpass/access/vault.ts +++ b/libs/importer/src/importers/lastpass/access/vault.ts @@ -3,16 +3,16 @@ import { HttpStatusCode } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { Account } from "./account"; -import { Client } from "./client"; -import { ClientInfo } from "./client-info"; -import { CryptoUtils } from "./crypto-utils"; -import { FederatedUserContext } from "./federated-user-context"; -import { Parser } from "./parser"; -import { ParserOptions } from "./parser-options"; -import { RestClient } from "./rest-client"; +import { IdpProvider } from "./enums"; +import { + Account, + ClientInfo, + FederatedUserContext, + ParserOptions, + UserTypeContext, +} from "./models"; +import { Client, CryptoUtils, Parser, RestClient } from "./services"; import { Ui } from "./ui"; -import { Provider, UserTypeContext } from "./user-type-context"; export class Vault { accounts: Account[]; @@ -47,7 +47,7 @@ export class Vault { parserOptions: ParserOptions = ParserOptions.default ): Promise { if (federatedUser == null) { - throw "Federated user context is not set."; + throw new Error("Federated user context is not set."); } const k1 = await this.getK1(federatedUser); const k2 = await this.getK2(federatedUser); @@ -77,32 +77,33 @@ export class Vault { this.userType.PkceEnabled = json.PkceEnabled; this.userType.Provider = json.Provider; this.userType.type = json.type; + return; } - throw "Cannot determine LastPass user type."; + throw new Error("Cannot determine LastPass user type."); } private async getK1(federatedUser: FederatedUserContext): Promise { if (this.userType == null) { - throw "User type is not set."; + throw new Error("User type is not set."); } if (!this.userType.isFederated()) { - throw "Cannot get k1 for LastPass user that is not federated."; + throw new Error("Cannot get k1 for LastPass user that is not federated."); } if (federatedUser == null) { - throw "Federated user is not set."; + throw new Error("Federated user is not set."); } let k1: Uint8Array = null; if (federatedUser.idpUserInfo?.LastPassK1 !== null) { return Utils.fromByteStringToArray(federatedUser.idpUserInfo.LastPassK1); - } else if (this.userType.Provider === Provider.Azure) { + } else if (this.userType.Provider === IdpProvider.Azure) { k1 = await this.getK1Azure(federatedUser); - } else if (this.userType.Provider === Provider.Google) { + } else if (this.userType.Provider === IdpProvider.Google) { k1 = await this.getK1Google(federatedUser); } else { - const b64Encoded = this.userType.Provider === Provider.PingOne; + const b64Encoded = this.userType.Provider === IdpProvider.PingOne; k1 = this.getK1FromAccessToken(federatedUser, b64Encoded); } @@ -110,7 +111,7 @@ export class Vault { return k1; } - throw "Cannot get k1."; + throw new Error("Cannot get k1."); } private async getK1Azure(federatedUser: FederatedUserContext) { @@ -175,11 +176,11 @@ export class Vault { private async getK2(federatedUser: FederatedUserContext): Promise { if (this.userType == null) { - throw "User type is not set."; + throw new Error("User type is not set."); } if (!this.userType.isFederated()) { - throw "Cannot get k2 for LastPass user that is not federated."; + throw new Error("Cannot get k2 for LastPass user that is not federated."); } const rest = new RestClient(); @@ -195,6 +196,6 @@ export class Vault { return Utils.fromB64ToArray(k2); } } - throw "Cannot get k2."; + throw new Error("Cannot get k2."); } } From 3dab77ba9e28b02bdee42fa06c69e135257b3c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Fri, 13 Oct 2023 12:20:27 +0200 Subject: [PATCH 10/85] [PM-3680] Remove ipcRenderer from desktop-theming.service (#6478) --- apps/desktop/src/app/services/desktop-theming.service.ts | 7 ++----- apps/desktop/src/platform/preload.ts | 7 ++++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/services/desktop-theming.service.ts b/apps/desktop/src/app/services/desktop-theming.service.ts index 21277dfd736..3157ad9f661 100644 --- a/apps/desktop/src/app/services/desktop-theming.service.ts +++ b/apps/desktop/src/app/services/desktop-theming.service.ts @@ -1,5 +1,4 @@ import { Injectable } from "@angular/core"; -import { ipcRenderer } from "electron"; import { ThemingService } from "@bitwarden/angular/services/theming/theming.service"; import { ThemeType } from "@bitwarden/common/enums"; @@ -7,12 +6,10 @@ import { ThemeType } from "@bitwarden/common/enums"; @Injectable() export class DesktopThemingService extends ThemingService { protected async getSystemTheme(): Promise { - return await ipcRenderer.invoke("systemTheme"); + return await ipc.platform.getSystemTheme(); } protected monitorSystemThemeChanges(): void { - ipcRenderer.on("systemThemeUpdated", (_event, theme: ThemeType) => - this.updateSystemTheme(theme) - ); + ipc.platform.onSystemThemeUpdated((theme: ThemeType) => this.updateSystemTheme(theme)); } } diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index b8aed8f65d1..1ea4f3b91b4 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -1,6 +1,6 @@ import { ipcRenderer } from "electron"; -import { DeviceType } from "@bitwarden/common/enums/device-type.enum"; +import { DeviceType, ThemeType } from "@bitwarden/common/enums"; import { isDev, isWindowsStore } from "../utils"; @@ -12,6 +12,11 @@ export default { isDev: isDev(), isWindowsStore: isWindowsStore(), reloadProcess: () => ipcRenderer.send("reload-process"), + + getSystemTheme: (): Promise => ipcRenderer.invoke("systemTheme"), + onSystemThemeUpdated: (callback: (theme: ThemeType) => void) => { + ipcRenderer.on("systemThemeUpdated", (_event, theme: ThemeType) => callback(theme)); + }, }; function deviceType(): DeviceType { From bb9de00ad715f5932ec1897859d41815b78deedc Mon Sep 17 00:00:00 2001 From: Will Martin Date: Fri, 13 Oct 2023 10:26:56 -0400 Subject: [PATCH 11/85] [PM-4300] downgrade electron to v25.9.1 (#6573) --- apps/desktop/electron-builder.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 0830fabf13d..69d1c0074fa 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -19,7 +19,7 @@ "**/node_modules/@bitwarden/desktop-native/index.js", "**/node_modules/@bitwarden/desktop-native/desktop_native.${platform}-${arch}*.node" ], - "electronVersion": "26.3.0", + "electronVersion": "25.9.1", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", diff --git a/package-lock.json b/package-lock.json index 0c1a4a69ea2..d6436616218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,7 @@ "cross-env": "7.0.3", "css-loader": "6.8.1", "del": "6.1.1", - "electron": "26.3.0", + "electron": "25.9.1", "electron-builder": "^23.6.0", "electron-log": "4.4.8", "electron-reload": "2.0.0-alpha.1", @@ -20179,9 +20179,9 @@ } }, "node_modules/electron": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-26.3.0.tgz", - "integrity": "sha512-7ZpvSHu+jmqialSvywTZnOQZZGLqlyj+yV5HGDrEzFnMiFaXBRpbByHgoUhaExJ/8t/0xKQjKlMRAY65w+zNZQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-25.9.1.tgz", + "integrity": "sha512-Uo/Fh7igjoUXA/f90iTATZJesQEArVL1uLA672JefNWTLymdKSZkJKiCciu/Xnd0TS6qvdIOUGuJFSTQnKskXQ==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/package.json b/package.json index 2cd1718c1e5..763897c388c 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "cross-env": "7.0.3", "css-loader": "6.8.1", "del": "6.1.1", - "electron": "26.3.0", + "electron": "25.9.1", "electron-builder": "^23.6.0", "electron-log": "4.4.8", "electron-reload": "2.0.0-alpha.1", From 95d4406a7ef1fbb98eef55445c679689aa2d37ee Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Fri, 13 Oct 2023 18:22:59 +0200 Subject: [PATCH 12/85] Update duo and add allow-popups and allow-popups-to-escape-sandbox (#6561) --- apps/browser/src/auth/popup/two-factor.component.html | 5 ++++- apps/desktop/src/auth/two-factor.component.html | 5 ++++- apps/web/src/app/auth/two-factor.component.html | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/auth/popup/two-factor.component.html b/apps/browser/src/auth/popup/two-factor.component.html index 7fec67378cc..d03c675abdc 100644 --- a/apps/browser/src/auth/popup/two-factor.component.html +++ b/apps/browser/src/auth/popup/two-factor.component.html @@ -113,7 +113,10 @@

" >
- +
diff --git a/apps/desktop/src/auth/two-factor.component.html b/apps/desktop/src/auth/two-factor.component.html index cd21f91f59e..2b9a1722ee0 100644 --- a/apps/desktop/src/auth/two-factor.component.html +++ b/apps/desktop/src/auth/two-factor.component.html @@ -83,7 +83,10 @@

{{ title }}

" >
- +
diff --git a/apps/web/src/app/auth/two-factor.component.html b/apps/web/src/app/auth/two-factor.component.html index e3617a16589..99d40e1f3e0 100644 --- a/apps/web/src/app/auth/two-factor.component.html +++ b/apps/web/src/app/auth/two-factor.component.html @@ -92,7 +92,7 @@
From ee2f2e1fb13f3039bcc26b14e6a93f0ba0217686 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 13 Oct 2023 13:38:48 -0400 Subject: [PATCH 13/85] [PM-4127] Bugfix - Check original target tab URL before executing deferred action due to reprompt (#6434) * remove solve for pm-3613 (will readdress in pm-4014) * check original target tab URL before executing deferred action due to reprompt * only check if target tab host+path changed during reprompt --- apps/browser/src/autofill/content/autofill.js | 5 --- .../insert-autofill-content.service.spec.ts | 34 ------------------- .../insert-autofill-content.service.ts | 13 +------ .../popup/components/vault/view.component.ts | 12 ++++++- 4 files changed, 12 insertions(+), 52 deletions(-) diff --git a/apps/browser/src/autofill/content/autofill.js b/apps/browser/src/autofill/content/autofill.js index 2f3857d3fa8..ef0fb73408b 100644 --- a/apps/browser/src/autofill/content/autofill.js +++ b/apps/browser/src/autofill/content/autofill.js @@ -993,11 +993,6 @@ function fillTheElement(el, op) { var shouldCheck; if (el && null !== op && void 0 !== op && !(el.disabled || el.a || el.readOnly)) { - const tabURLChanged = !fillScript.savedUrls?.some(url => url.startsWith(window.location.origin)) - // Check to make sure the page location didn't change - if (tabURLChanged) { - return; - } switch (markTheFilling && el.form && !el.form.opfilled && (el.form.opfilled = true), el.type ? el.type.toLowerCase() : null) { case 'checkbox': diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts index 0ab74875fbf..828d768ca25 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -108,7 +108,6 @@ describe("InsertAutofillContentService", () => { jest.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe"); jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); - jest.spyOn(insertAutofillContentService as any, "tabURLChanged"); jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); insertAutofillContentService.fillForm(fillScript); @@ -120,7 +119,6 @@ describe("InsertAutofillContentService", () => { expect( insertAutofillContentService["userCancelledUntrustedIframeAutofill"] ).not.toHaveBeenCalled(); - expect(insertAutofillContentService["tabURLChanged"]).not.toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); }); @@ -130,7 +128,6 @@ describe("InsertAutofillContentService", () => { .mockReturnValue(true); jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); - jest.spyOn(insertAutofillContentService as any, "tabURLChanged"); jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); insertAutofillContentService.fillForm(fillScript); @@ -142,7 +139,6 @@ describe("InsertAutofillContentService", () => { expect( insertAutofillContentService["userCancelledUntrustedIframeAutofill"] ).not.toHaveBeenCalled(); - expect(insertAutofillContentService["tabURLChanged"]).not.toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); }); @@ -154,7 +150,6 @@ describe("InsertAutofillContentService", () => { .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") .mockReturnValue(true); jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); - jest.spyOn(insertAutofillContentService as any, "tabURLChanged"); jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); insertAutofillContentService.fillForm(fillScript); @@ -164,7 +159,6 @@ describe("InsertAutofillContentService", () => { expect( insertAutofillContentService["userCancelledUntrustedIframeAutofill"] ).not.toHaveBeenCalled(); - expect(insertAutofillContentService["tabURLChanged"]).not.toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); }); @@ -178,7 +172,6 @@ describe("InsertAutofillContentService", () => { jest .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") .mockReturnValue(true); - jest.spyOn(insertAutofillContentService as any, "tabURLChanged").mockReturnValue(false); jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); insertAutofillContentService.fillForm(fillScript); @@ -188,31 +181,6 @@ describe("InsertAutofillContentService", () => { expect( insertAutofillContentService["userCancelledUntrustedIframeAutofill"] ).toHaveBeenCalled(); - expect(insertAutofillContentService["tabURLChanged"]).not.toHaveBeenCalled(); - expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); - }); - - it("returns early if the page location origin does not match against any of the cipher saved URLs", () => { - jest - .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") - .mockReturnValue(false); - jest - .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") - .mockReturnValue(false); - jest - .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") - .mockReturnValue(false); - jest.spyOn(insertAutofillContentService as any, "tabURLChanged").mockReturnValue(true); - jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); - - insertAutofillContentService.fillForm(fillScript); - - expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); - expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); - expect( - insertAutofillContentService["userCancelledUntrustedIframeAutofill"] - ).toHaveBeenCalled(); - expect(insertAutofillContentService["tabURLChanged"]).toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); }); @@ -226,7 +194,6 @@ describe("InsertAutofillContentService", () => { jest .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") .mockReturnValue(false); - jest.spyOn(insertAutofillContentService as any, "tabURLChanged").mockReturnValue(false); jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); insertAutofillContentService.fillForm(fillScript); @@ -236,7 +203,6 @@ describe("InsertAutofillContentService", () => { expect( insertAutofillContentService["userCancelledUntrustedIframeAutofill"] ).toHaveBeenCalled(); - expect(insertAutofillContentService["tabURLChanged"]).toHaveBeenCalled(); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenCalledTimes(3); expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( 1, diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index ad40b76fbcd..46cb53d4f59 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -38,8 +38,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf !fillScript.script?.length || this.fillingWithinSandboxedIframe() || this.userCancelledInsecureUrlAutofill(fillScript.savedUrls) || - this.userCancelledUntrustedIframeAutofill(fillScript) || - this.tabURLChanged(fillScript.savedUrls) + this.userCancelledUntrustedIframeAutofill(fillScript) ) { return; } @@ -47,16 +46,6 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf fillScript.script.forEach(this.runFillScriptAction); } - /** - * Determines if the page URL no longer matches one of the cipher's savedURL domains - * @param {string[] | null} savedUrls - * @returns {boolean} - * @private - */ - private tabURLChanged(savedUrls?: AutofillScript["savedUrls"]): boolean { - return savedUrls && !savedUrls.some((url) => url.startsWith(window.location.origin)); - } - /** * Identifies if the execution of this script is happening * within a sandboxed iframe. diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index 6c9f3967d56..29027b33505 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -331,11 +331,21 @@ export class ViewComponent extends BaseViewComponent { } private async doAutofill() { + const originalTabURL = this.tab.url?.length && new URL(this.tab.url); + if (!(await this.promptPassword())) { return false; } - if (this.pageDetails == null || this.pageDetails.length === 0) { + const currentTabURL = this.tab.url?.length && new URL(this.tab.url); + + const originalTabHostPath = + originalTabURL && `${originalTabURL.origin}${originalTabURL.pathname}`; + const currentTabHostPath = currentTabURL && `${currentTabURL.origin}${currentTabURL.pathname}`; + + const tabUrlChanged = originalTabHostPath !== currentTabHostPath; + + if (this.pageDetails == null || this.pageDetails.length === 0 || tabUrlChanged) { this.platformUtilsService.showToast("error", null, this.i18nService.t("autofillError")); return false; } From 2dc94ede97ddf6d6eec41bb03b849fa540cf9956 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 16 Oct 2023 15:43:38 +0200 Subject: [PATCH 14/85] [PM-3538] Migrate AddOrganizationComponent Web (#6275) Migrate add organization in provider portal to use component library. --- .../clients/add-organization.component.html | 72 +++++---------- .../clients/add-organization.component.ts | 91 ++++++++++--------- .../providers/clients/clients.component.html | 2 - .../providers/clients/clients.component.ts | 30 ++---- .../providers/providers.module.ts | 12 +-- libs/angular/src/services/modal.service.ts | 7 -- 6 files changed, 84 insertions(+), 130 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.html index 01cbc3c0bc5..0fa39f2f292 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.html @@ -1,47 +1,25 @@ -

{{ credential.name }} + {{ "supportsEncryption" | i18n }} @@ -31,7 +31,7 @@

{{ "encryptionNotSupported" | i18n }}

+
- - - - - -
- - - {{ o.name }} - - -
- - - - - + + {{ "addExistingOrganization" | i18n }} + + + + + + + + + {{ o.name }} + + + + + + + + + + + + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.ts index d3eff4bc53c..0d61c264e29 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/add-organization.component.ts @@ -1,4 +1,5 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -10,20 +11,21 @@ import { DialogService } from "@bitwarden/components"; import { WebProviderService } from "../services/web-provider.service"; +interface AddOrganizationDialogData { + providerId: string; + organizations: Organization[]; +} + @Component({ - selector: "provider-add-organization", templateUrl: "add-organization.component.html", }) export class AddOrganizationComponent implements OnInit { - @Input() providerId: string; - @Input() organizations: Organization[]; - @Output() onAddedOrganization = new EventEmitter(); - - provider: Provider; - formPromise: Promise; - loading = true; + protected provider: Provider; + protected loading = true; constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) protected data: AddOrganizationDialogData, private providerService: ProviderService, private webProviderService: WebProviderService, private i18nService: I18nService, @@ -37,52 +39,53 @@ export class AddOrganizationComponent implements OnInit { } async load() { - if (this.providerId == null) { + if (this.data.providerId == null) { return; } - this.provider = await this.providerService.get(this.providerId); + this.provider = await this.providerService.get(this.data.providerId); this.loading = false; } - async add(organization: Organization) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - if (this.formPromise) { - return; - } + add(organization: Organization) { + return async () => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.name, + content: { + key: "addOrganizationConfirmation", + placeholders: [organization.name, this.provider.name], + }, + type: "warning", + }); - const confirmed = await this.dialogService.openSimpleDialog({ - title: organization.name, - content: { - key: "addOrganizationConfirmation", - placeholders: [organization.name, this.provider.name], - }, - type: "warning", - }); + if (!confirmed) { + return false; + } - if (!confirmed) { - return false; - } + try { + await this.webProviderService.addOrganizationToProvider( + this.data.providerId, + organization.id + ); + } catch (e) { + this.validationService.showError(e); + return; + } - try { - this.formPromise = this.webProviderService.addOrganizationToProvider( - this.providerId, - organization.id + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("organizationJoinedProvider") ); - await this.formPromise; - } catch (e) { - this.validationService.showError(e); - return; - } finally { - this.formPromise = null; - } - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("organizationJoinedProvider") - ); - this.onAddedOrganization.emit(); + this.dialogRef.close(true); + }; + } + + static open(dialogService: DialogService, data: AddOrganizationDialogData) { + return dialogService.open(AddOrganizationComponent, { + data, + }); } } 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 59d07f7def8..9e3aaf4d29e 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 @@ -97,5 +97,3 @@

{{ "clients" | i18n }}

- - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts index 329bf5189e7..758c8120353 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts @@ -1,5 +1,6 @@ -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -33,8 +34,6 @@ const DisallowedPlanTypes = [ }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class ClientsComponent implements OnInit { - @ViewChild("add", { read: ViewContainerRef, static: true }) addModalRef: ViewContainerRef; - providerId: string; searchText: string; addableOrganizations: Organization[]; @@ -135,23 +134,14 @@ export class ClientsComponent implements OnInit { } async addExistingOrganization() { - const [modal] = await this.modalService.openViewRef( - AddOrganizationComponent, - this.addModalRef, - (comp) => { - comp.providerId = this.providerId; - comp.organizations = this.addableOrganizations; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - comp.onAddedOrganization.subscribe(async () => { - try { - await this.load(); - modal.close(); - } catch (e) { - this.logService.error(`Handled exception: ${e}`); - } - }); - } - ); + const dialogRef = AddOrganizationComponent.open(this.dialogService, { + providerId: this.providerId, + organizations: this.addableOrganizations, + }); + + if (await firstValueFrom(dialogRef.closed)) { + await this.load(); + } } async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index b7f3bf9f382..7995e14825f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -1,9 +1,8 @@ import { CommonModule } from "@angular/common"; -import { ComponentFactoryResolver, NgModule } from "@angular/core"; +import { NgModule } from "@angular/core"; import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; import { SearchModule } from "@bitwarden/components"; import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; import { OssModule } from "@bitwarden/web-vault/app/oss.module"; @@ -56,11 +55,4 @@ import { SetupComponent } from "./setup/setup.component"; ], providers: [WebProviderService, ProviderPermissionsGuard], }) -export class ProvidersModule { - constructor(modalService: ModalService, componentFactoryResolver: ComponentFactoryResolver) { - modalService.registerComponentFactoryResolver( - AddOrganizationComponent, - componentFactoryResolver - ); - } -} +export class ProvidersModule {} diff --git a/libs/angular/src/services/modal.service.ts b/libs/angular/src/services/modal.service.ts index ba461764ba8..da47368c2fa 100644 --- a/libs/angular/src/services/modal.service.ts +++ b/libs/angular/src/services/modal.service.ts @@ -88,13 +88,6 @@ export class ModalService { return modalRef; } - registerComponentFactoryResolver( - componentType: Type, - componentFactoryResolver: ComponentFactoryResolver - ): void { - this.factoryResolvers.set(componentType, componentFactoryResolver); - } - resolveComponentFactory(componentType: Type): ComponentFactory { if (this.factoryResolvers.has(componentType)) { return this.factoryResolvers.get(componentType).resolveComponentFactory(componentType); From c3856ce821cab1ce4f457f78cc0b1175ad1241e2 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:29:03 -0400 Subject: [PATCH 15/85] [SM-896] When org is disabled disable the logic and show warning symbols (#6225) * When org is disabled disable the logic and show warning symbols * fixing org enabled logic * removing unused code * Adding route gaurd logic and new org suspended page * fixing lint issue * fixing issues * Requested changes * adding back code that was accidentally removed from organization-switcher * Update bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Removing unused code and updating storybook to set enabled:true * removing onDestroy * Will's suggestions * will's suggested change * fix nav-item color in story * Thomas Rittson's suggested changes * adding back removed spaces * Adding back white space * updating guard * Update bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * removing ununsed data * Updating incorrect messages --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Co-authored-by: William Martin Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .../product-switcher-content.component.ts | 7 +- .../product-switcher.stories.ts | 2 +- apps/web/src/locales/en/messages.json | 12 +++ .../guards/sm-org-enabled.guard.ts | 29 +++++++ .../secrets-manager/{ => guards}/sm.guard.ts | 0 .../layout/org-switcher.component.html | 10 +++ .../layout/org-switcher.component.ts | 8 +- .../overview/overview.component.ts | 8 ++ .../dialog/project-dialog.component.ts | 10 +++ .../project/project-secrets.component.ts | 8 +- .../projects/project/project.component.ts | 8 +- .../projects/projects/projects.component.ts | 9 +- .../secrets/dialog/secret-dialog.component.ts | 6 ++ .../secrets/secrets.component.ts | 9 +- .../service-account-dialog.component.ts | 10 +++ .../service-accounts.component.ts | 9 +- .../shared/new-menu.component.ts | 13 ++- .../shared/org-suspended.component.html | 7 ++ .../shared/org-suspended.component.ts | 18 ++++ .../shared/sm-shared.module.ts | 2 + .../app/secrets-manager/sm-routing.module.ts | 87 ++++++++++--------- libs/components/src/icon/icons/index.ts | 1 + libs/components/src/icon/icons/no-access.ts | 12 +++ .../src/navigation/nav-group.component.html | 9 +- .../src/navigation/nav-item.component.html | 2 +- .../src/navigation/nav-item.stories.ts | 6 +- 26 files changed, 242 insertions(+), 60 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts rename bitwarden_license/bit-web/src/app/secrets-manager/{ => guards}/sm.guard.ts (100%) create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.html create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts create mode 100644 libs/components/src/icon/icons/no-access.ts diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.ts b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.ts index e0705dd070b..7a637c642b4 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.ts @@ -47,9 +47,10 @@ export class ProductSwitcherContentComponent { map(([orgs, paramMap]) => { const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId")); // If the active route org doesn't have access to SM, find the first org that does. - const smOrg = routeOrg?.canAccessSecretsManager - ? routeOrg - : orgs.find((o) => o.canAccessSecretsManager); + const smOrg = + routeOrg?.canAccessSecretsManager && routeOrg?.enabled == true + ? routeOrg + : orgs.find((o) => o.canAccessSecretsManager && o.enabled == true); /** * We can update this to the "satisfies" type upon upgrading to TypeScript 4.9 diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 87e42027476..46a2df458b0 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -131,5 +131,5 @@ OrgWithoutSecretsManager.args = { export const OrgWithSecretsManager = Template.bind({}); OrgWithSecretsManager.args = { - mockOrgs: [{ id: "b", canAccessSecretsManager: true }], + mockOrgs: [{ id: "b", canAccessSecretsManager: true, enabled: true }], }; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b9d613877f2..60fed7a538e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3638,6 +3638,18 @@ "organizationIsDisabled": { "message": "Organization suspended" }, + "secretsAccessSuspended": { + "message": "Suspended organizations cannot be accessed. Please contact your organization owner for assistance." + }, + "secretsCannotCreate": { + "message": "Secrets cannot be created in suspended organizations. Please contact your organization owner for assistance." + }, + "projectsCannotCreate": { + "message": "Projects cannot be created in suspended organizations. Please contact your organization owner for assistance." + }, + "serviceAccountsCannotCreate": { + "message": "Service accounts cannot be created in suspended organizations. Please contact your organization owner for assistance." + }, "disabledOrganizationFilterError": { "message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance." }, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts new file mode 100644 index 00000000000..3ff4d998a3d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm-org-enabled.guard.ts @@ -0,0 +1,29 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +/** + * Redirects from root `/sm` to first organization with access to SM + */ +export const organizationEnabledGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const syncService = inject(SyncService); + const orgService = inject(OrganizationService); + + /** Workaround to avoid service initialization race condition. */ + if ((await syncService.getLastSync()) == null) { + await syncService.fullSync(false); + } + + const org = orgService.get(route.params.organizationId); + if (org == null || !org.canAccessSecretsManager) { + return createUrlTreeFromSnapshot(route, ["/"]); + } + + if (!org.enabled) { + return createUrlTreeFromSnapshot(route, ["/sm", org.id, "organization-suspended"]); + } + + return true; +}; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/guards/sm.guard.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts rename to bitwarden_license/bit-web/src/app/secrets-manager/guards/sm.guard.ts diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html index e639c5f126d..d7a404bf1dd 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/org-switcher.component.html @@ -7,6 +7,11 @@ [(open)]="open" [exactMatch]="true" > + + = this.organizationService.organizations$.pipe( - map((orgs) => orgs.filter(this.filter).sort((a, b) => a.name.localeCompare(b.name))) + map((orgs) => + orgs + .filter((org) => this.filter(org)) + .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => (a.enabled ? -1 : 1)) + ) ); + protected activeOrganization$: Observable = combineLatest([ this.route.paramMap, this.organizations$, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 86fab25608a..868026a8431 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -70,6 +70,7 @@ export class OverviewComponent implements OnInit, OnDestroy { protected userIsAdmin: boolean; protected showOnboarding = false; protected loading = true; + protected organizationEnabled = false; protected view$: Observable<{ allProjects: ProjectListView[]; @@ -107,6 +108,7 @@ export class OverviewComponent implements OnInit, OnDestroy { this.organizationName = org.name; this.userIsAdmin = org.isAdmin; this.loading = true; + this.organizationEnabled = org.enabled; }); const projects$ = combineLatest([ @@ -208,6 +210,7 @@ export class OverviewComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Edit, + organizationEnabled: this.organizationEnabled, projectId: projectId, }, }); @@ -218,6 +221,7 @@ export class OverviewComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } @@ -227,6 +231,7 @@ export class OverviewComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } @@ -246,6 +251,7 @@ export class OverviewComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } @@ -256,6 +262,7 @@ export class OverviewComponent implements OnInit, OnDestroy { organizationId: this.organizationId, operation: OperationType.Edit, secretId: secretId, + organizationEnabled: this.organizationEnabled, }, }); } @@ -273,6 +280,7 @@ export class OverviewComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index a6a3c958d09..3fd723c7580 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -18,6 +18,7 @@ export enum OperationType { export interface ProjectOperation { organizationId: string; operation: OperationType; + organizationEnabled: boolean; projectId?: string; } @@ -63,6 +64,15 @@ export class ProjectDialogComponent implements OnInit { } submit = async () => { + if (!this.data.organizationEnabled) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("projectsCannotCreate") + ); + return; + } + this.formGroup.markAllAsTouched(); if (this.formGroup.invalid) { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index 2d1690ef0ec..a952a351537 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -2,6 +2,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, combineLatestWith, filter, Observable, startWith, switchMap } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -31,6 +32,7 @@ export class ProjectSecretsComponent { private organizationId: string; private projectId: string; protected project$: Observable; + private organizationEnabled: boolean; constructor( private route: ActivatedRoute, @@ -38,7 +40,8 @@ export class ProjectSecretsComponent { private secretService: SecretService, private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService + private i18nService: I18nService, + private organizationService: OrganizationService ) {} ngOnInit() { @@ -60,6 +63,7 @@ export class ProjectSecretsComponent { switchMap(async ([_, params]) => { this.organizationId = params.organizationId; this.projectId = params.projectId; + this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; return await this.getSecretsByProject(); }) ); @@ -75,6 +79,7 @@ export class ProjectSecretsComponent { organizationId: this.organizationId, operation: OperationType.Edit, secretId: secretId, + organizationEnabled: this.organizationEnabled, }, }); } @@ -93,6 +98,7 @@ export class ProjectSecretsComponent { organizationId: this.organizationId, operation: OperationType.Add, projectId: this.projectId, + organizationEnabled: this.organizationEnabled, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index c87d238d6a8..148ccc79d26 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -12,6 +12,7 @@ import { takeUntil, } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -33,7 +34,7 @@ export class ProjectComponent implements OnInit, OnDestroy { private organizationId: string; private projectId: string; - + private organizationEnabled: boolean; private destroy$ = new Subject(); constructor( @@ -42,7 +43,8 @@ export class ProjectComponent implements OnInit, OnDestroy { private router: Router, private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService + private i18nService: I18nService, + private organizationService: OrganizationService ) {} ngOnInit(): void { @@ -69,6 +71,7 @@ export class ProjectComponent implements OnInit, OnDestroy { this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => { this.organizationId = params.organizationId; this.projectId = params.projectId; + this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; }); } @@ -82,6 +85,7 @@ export class ProjectComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Edit, + organizationEnabled: this.organizationEnabled, projectId: this.projectId, }, }); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts index 7128e26a3d8..1066828f216 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, lastValueFrom, Observable, startWith, switchMap } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { DialogService } from "@bitwarden/components"; import { ProjectListView } from "../../models/view/project-list.view"; @@ -32,12 +33,14 @@ export class ProjectsComponent implements OnInit { protected search: string; private organizationId: string; + private organizationEnabled: boolean; constructor( private route: ActivatedRoute, private projectService: ProjectService, private accessPolicyService: AccessPolicyService, - private dialogService: DialogService + private dialogService: DialogService, + private organizationService: OrganizationService ) {} ngOnInit() { @@ -48,6 +51,8 @@ export class ProjectsComponent implements OnInit { ]).pipe( switchMap(async ([params]) => { this.organizationId = params.organizationId; + this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + return await this.getProjects(); }) ); @@ -62,6 +67,7 @@ export class ProjectsComponent implements OnInit { data: { organizationId: this.organizationId, operation: OperationType.Edit, + organizationEnabled: this.organizationEnabled, projectId: projectId, }, }); @@ -72,6 +78,7 @@ export class ProjectsComponent implements OnInit { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index 426542823f9..70eca54e3c5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -29,6 +29,7 @@ export interface SecretOperation { operation: OperationType; projectId?: string; secretId?: string; + organizationEnabled: boolean; } @Component({ @@ -163,6 +164,11 @@ export class SecretDialogComponent implements OnInit { } submit = async () => { + if (!this.data.organizationEnabled) { + this.platformUtilsService.showToast("error", null, this.i18nService.t("secretsCannotCreate")); + return; + } + this.formGroup.markAllAsTouched(); if (this.formGroup.invalid) { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index 7c05f169a3d..b23393de60a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; @@ -29,13 +30,15 @@ export class SecretsComponent implements OnInit { protected search: string; private organizationId: string; + private organizationEnabled: boolean; constructor( private route: ActivatedRoute, private secretService: SecretService, private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService + private i18nService: I18nService, + private organizationService: OrganizationService ) {} ngOnInit() { @@ -44,6 +47,8 @@ export class SecretsComponent implements OnInit { combineLatestWith(this.route.params), switchMap(async ([_, params]) => { this.organizationId = params.organizationId; + this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + return await this.getSecrets(); }) ); @@ -63,6 +68,7 @@ export class SecretsComponent implements OnInit { organizationId: this.organizationId, operation: OperationType.Edit, secretId: secretId, + organizationEnabled: this.organizationEnabled, }, }); } @@ -80,6 +86,7 @@ export class SecretsComponent implements OnInit { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 1f42537f956..decd042cc14 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -18,6 +18,7 @@ export interface ServiceAccountOperation { organizationId: string; serviceAccountId?: string; operation: OperationType; + organizationEnabled: boolean; } @Component({ @@ -62,6 +63,15 @@ export class ServiceAccountDialogComponent { } submit = async () => { + if (!this.data.organizationEnabled) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("serviceAccountsCannotCreate") + ); + return; + } + this.formGroup.markAllAsTouched(); if (this.formGroup.invalid) { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts index 808073ba810..bebd9ddca62 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, Observable, startWith, switchMap } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { DialogService } from "@bitwarden/components"; import { @@ -30,12 +31,14 @@ export class ServiceAccountsComponent implements OnInit { protected search: string; private organizationId: string; + private organizationEnabled: boolean; constructor( private route: ActivatedRoute, private dialogService: DialogService, private accessPolicyService: AccessPolicyService, - private serviceAccountService: ServiceAccountService + private serviceAccountService: ServiceAccountService, + private organizationService: OrganizationService ) {} ngOnInit() { @@ -46,6 +49,8 @@ export class ServiceAccountsComponent implements OnInit { ]).pipe( switchMap(async ([params]) => { this.organizationId = params.organizationId; + this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; + return await this.getServiceAccounts(); }) ); @@ -56,6 +61,7 @@ export class ServiceAccountsComponent implements OnInit { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } @@ -66,6 +72,7 @@ export class ServiceAccountsComponent implements OnInit { organizationId: this.organizationId, serviceAccountId: serviceAccountId, operation: OperationType.Edit, + organizationEnabled: this.organizationEnabled, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts index 7ecc2f917a4..67a93e8ad87 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts @@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { DialogService } from "@bitwarden/components"; import { @@ -24,13 +25,18 @@ import { }) export class NewMenuComponent implements OnInit, OnDestroy { private organizationId: string; + private organizationEnabled: boolean; private destroy$: Subject = new Subject(); - - constructor(private route: ActivatedRoute, private dialogService: DialogService) {} + constructor( + private route: ActivatedRoute, + private dialogService: DialogService, + private organizationService: OrganizationService + ) {} ngOnInit() { this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { this.organizationId = params.organizationId; + this.organizationEnabled = this.organizationService.get(params.organizationId)?.enabled; }); } @@ -44,6 +50,7 @@ export class NewMenuComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } @@ -53,6 +60,7 @@ export class NewMenuComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } @@ -62,6 +70,7 @@ export class NewMenuComponent implements OnInit, OnDestroy { data: { organizationId: this.organizationId, operation: OperationType.Add, + organizationEnabled: this.organizationEnabled, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.html new file mode 100644 index 00000000000..8de68f65988 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.html @@ -0,0 +1,7 @@ + + + + + {{ "organizationIsDisabled" | i18n }} + {{ "secretsAccessSuspended" | i18n }} + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts new file mode 100644 index 00000000000..73f89c0826d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -0,0 +1,18 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { map } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Icon, Icons } from "@bitwarden/components"; + +@Component({ + templateUrl: "./org-suspended.component.html", +}) +export class OrgSuspendedComponent { + constructor(private organizationService: OrganizationService, private route: ActivatedRoute) {} + + protected NoAccess: Icon = Icons.NoAccess; + protected organizationName$ = this.route.params.pipe( + map((params) => this.organizationService.get(params.organizationId)?.name) + ); +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts index 6d59503b50a..d2990f4c67f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/sm-shared.module.ts @@ -17,6 +17,7 @@ import { BulkConfirmationDialogComponent } from "./dialogs/bulk-confirmation-dia import { BulkStatusDialogComponent } from "./dialogs/bulk-status-dialog.component"; import { HeaderComponent } from "./header.component"; import { NewMenuComponent } from "./new-menu.component"; +import { OrgSuspendedComponent } from "./org-suspended.component"; import { ProjectsListComponent } from "./projects-list.component"; import { SecretsListComponent } from "./secrets-list.component"; @@ -55,6 +56,7 @@ import { SecretsListComponent } from "./secrets-list.component"; ProjectsListComponent, SecretsListComponent, AccessSelectorComponent, + OrgSuspendedComponent, ], providers: [], bootstrap: [], diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts index 5c18bab4e42..0cad3129a40 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts @@ -2,10 +2,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard"; import { buildFlaggedRoute } from "@bitwarden/web-vault/app/oss-routing.module"; +import { organizationEnabledGuard } from "./guards/sm-org-enabled.guard"; +import { canActivateSM } from "./guards/sm.guard"; import { LayoutComponent } from "./layout/layout.component"; import { NavigationComponent } from "./layout/navigation.component"; import { OverviewModule } from "./overview/overview.module"; @@ -13,7 +13,7 @@ import { ProjectsModule } from "./projects/projects.module"; import { SecretsModule } from "./secrets/secrets.module"; import { ServiceAccountsModule } from "./service-accounts/service-accounts.module"; import { SettingsModule } from "./settings/settings.module"; -import { canActivateSM } from "./sm.guard"; +import { OrgSuspendedComponent } from "./shared/org-suspended.component"; import { TrashModule } from "./trash/trash.module"; const routes: Routes = [ @@ -29,10 +29,7 @@ const routes: Routes = [ { path: ":organizationId", component: LayoutComponent, - canActivate: [AuthGuard, OrganizationPermissionsGuard], - data: { - organizationPermissions: (org: Organization) => org.canAccessSecretsManager, - }, + canActivate: [AuthGuard], children: [ { path: "", @@ -40,41 +37,51 @@ const routes: Routes = [ outlet: "sidebar", }, { - path: "secrets", - loadChildren: () => SecretsModule, - data: { - titleId: "secrets", - }, - }, - { - path: "projects", - loadChildren: () => ProjectsModule, - data: { - titleId: "projects", - }, - }, - { - path: "service-accounts", - loadChildren: () => ServiceAccountsModule, - data: { - titleId: "serviceAccounts", - }, - }, - { - path: "trash", - loadChildren: () => TrashModule, - data: { - titleId: "trash", - }, - }, - { - path: "settings", - loadChildren: () => SettingsModule, + path: "", + canActivate: [organizationEnabledGuard], + children: [ + { + path: "secrets", + loadChildren: () => SecretsModule, + data: { + titleId: "secrets", + }, + }, + { + path: "projects", + loadChildren: () => ProjectsModule, + data: { + titleId: "projects", + }, + }, + { + path: "service-accounts", + loadChildren: () => ServiceAccountsModule, + data: { + titleId: "serviceAccounts", + }, + }, + { + path: "trash", + loadChildren: () => TrashModule, + data: { + titleId: "trash", + }, + }, + { + path: "settings", + loadChildren: () => SettingsModule, + }, + { + path: "", + loadChildren: () => OverviewModule, + pathMatch: "full", + }, + ], }, { - path: "", - loadChildren: () => OverviewModule, - pathMatch: "full", + path: "organization-suspended", + component: OrgSuspendedComponent, }, ], }, diff --git a/libs/components/src/icon/icons/index.ts b/libs/components/src/icon/icons/index.ts index 03fdb729bc7..02cb975e095 100644 --- a/libs/components/src/icon/icons/index.ts +++ b/libs/components/src/icon/icons/index.ts @@ -1 +1,2 @@ export * from "./search"; +export * from "./no-access"; diff --git a/libs/components/src/icon/icons/no-access.ts b/libs/components/src/icon/icons/no-access.ts new file mode 100644 index 00000000000..f9ad048752a --- /dev/null +++ b/libs/components/src/icon/icons/no-access.ts @@ -0,0 +1,12 @@ +import { svgIcon } from "../icon"; + +export const NoAccess = svgIcon` + + + + + + + + +`; diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index ca9a7c3aecf..118f78a1865 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -17,7 +17,7 @@ [bitIconButton]=" open ? 'bwi-angle-up' : variant === 'tree' ? 'bwi-angle-right' : 'bwi-angle-down' " - [buttonType]="'main'" + [buttonType]="'light'" (click)="toggle($event)" size="small" [title]="'toggleCollapse' | i18n" @@ -32,8 +32,11 @@ - - + + + + + diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 32c8dfbf980..02705e821eb 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -73,7 +73,7 @@
diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 7fdbadce31a..c8f90eabcff 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -64,7 +64,7 @@ export const WithChildButtons: Story = { slot="start" class="tw-ml-auto" [bitIconButton]="'bwi-clone'" - [buttonType]="'contrast'" + [buttonType]="'light'" size="small" aria-label="option 1" > @@ -72,7 +72,7 @@ export const WithChildButtons: Story = { slot="end" class="tw-ml-auto" [bitIconButton]="'bwi-pencil-square'" - [buttonType]="'contrast'" + [buttonType]="'light'" size="small" aria-label="option 2" > @@ -80,7 +80,7 @@ export const WithChildButtons: Story = { slot="end" class="tw-ml-auto" [bitIconButton]="'bwi-check'" - [buttonType]="'contrast'" + [buttonType]="'light'" size="small" aria-label="option 3" > From b1a92ba04b96d6260e1445da7bad29948a41d6ca Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 16 Oct 2023 12:10:04 -0400 Subject: [PATCH 16/85] [PM-2411] Update `billing-sync-api-key` to use Dialog (#6537) * Update billing-sync-api-key dialog * Oscar's feedback --- .../billing-sync-api-key.component.html | 186 +++++++----------- .../billing-sync-api-key.component.ts | 74 ++++--- ...ganization-subscription-cloud.component.ts | 30 +-- 3 files changed, 124 insertions(+), 166 deletions(-) diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html index a196845a395..f5225bc29a0 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html @@ -1,117 +1,75 @@ - + + + + {{ hostname }} + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts index 0b7625a3902..6b62b28ca6d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts @@ -5,6 +5,7 @@ import { combineLatest, map, Observable } from "rxjs"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; 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 { AccountProfile } from "@bitwarden/common/platform/models/domain/account"; @@ -26,10 +27,13 @@ export class HeaderComponent { protected routeData$: Observable<{ titleId: string }>; protected account$: Observable; protected canLock$: Observable; + protected selfHosted: boolean; + protected hostname = location.hostname; constructor( private route: ActivatedRoute, private stateService: StateService, + private platformUtilsService: PlatformUtilsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private messagingService: MessagingService ) { @@ -41,6 +45,8 @@ export class HeaderComponent { }) ); + this.selfHosted = this.platformUtilsService.isSelfHost(); + this.account$ = combineLatest([ this.stateService.activeAccount$, this.stateService.accounts$, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts index 8e480ae50a1..1231ba100a4 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts @@ -13,6 +13,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; 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 { AvatarModule, @@ -50,6 +51,12 @@ class MockVaultTimeoutService { } } +class MockPlatformUtilsService { + isSelfHost() { + return false; + } +} + @Component({ selector: "product-switcher", template: ``, @@ -97,6 +104,7 @@ export default { declarations: [HeaderComponent, MockProductSwitcher, MockDynamicAvatar], providers: [ { provide: StateService, useClass: MockStateService }, + { provide: PlatformUtilsService, useClass: MockPlatformUtilsService }, { provide: VaultTimeoutSettingsService, useClass: MockVaultTimeoutService }, { provide: MessagingService, From 1f26f6579db9c9120b1e4b005d6461bbaa222a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 17 Oct 2023 13:41:19 +0200 Subject: [PATCH 19/85] [PM-3684] Remove ipcRenderer from electron.renderer.messaging (#6480) --- apps/desktop/src/platform/preload.ts | 10 ++++++++++ .../services/electron-renderer-messaging.service.ts | 10 ++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 1ea4f3b91b4..d7c4dd430ed 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -17,6 +17,16 @@ export default { onSystemThemeUpdated: (callback: (theme: ThemeType) => void) => { ipcRenderer.on("systemThemeUpdated", (_event, theme: ThemeType) => callback(theme)); }, + + sendMessage: (message: { command: string } & any) => + ipcRenderer.send("messagingService", message), + onMessage: (callback: (message: { command: string } & any) => void) => { + ipcRenderer.on("messagingService", (_event, message: any) => { + if (message.command) { + callback(message); + } + }); + }, }; function deviceType(): DeviceType { diff --git a/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts b/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts index 25414812c42..192efc1dc6f 100644 --- a/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts +++ b/apps/desktop/src/platform/services/electron-renderer-messaging.service.ts @@ -1,15 +1,9 @@ -import { ipcRenderer } from "electron"; - import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; export class ElectronRendererMessagingService implements MessagingService { constructor(private broadcasterService: BroadcasterService) { - ipcRenderer.on("messagingService", async (event: any, message: any) => { - if (message.command) { - this.sendMessage(message.command, message, false); - } - }); + ipc.platform.onMessage((message) => this.sendMessage(message.command, message, false)); } send(subscriber: string, arg: any = {}) { @@ -20,7 +14,7 @@ export class ElectronRendererMessagingService implements MessagingService { const message = Object.assign({}, { command: subscriber }, arg); this.broadcasterService.send(message); if (toMain) { - ipcRenderer.send("messagingService", message); + ipc.platform.sendMessage(message); } } } From d4e6793871c1e408687331168c495884ceb605f7 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 17 Oct 2023 08:15:45 -0400 Subject: [PATCH 20/85] Re-include Electron updates with Renovate (#6602) --- .github/renovate.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index 81dea677317..57bcc8d2e4d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -43,13 +43,5 @@ "matchUpdateTypes": "major" } ], - "ignoreDeps": [ - "@types/koa-bodyparser", - "bootstrap", - "electron-builder", - "electron", - "node-ipc", - "regedit", - "zone.js" - ] + "ignoreDeps": ["@types/koa-bodyparser", "bootstrap", "node-ipc", "regedit", "zone.js"] } From 5cacd79d8c01a3b3d736792a0528a934621e7833 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 17 Oct 2023 15:56:59 +0100 Subject: [PATCH 21/85] Refactor StaticStore Plans and consuming logic (#6136) * staticstore factoring changes * Refactoring code changes * fix the free org issue * remove a depreciated endpoint * Resolve the issue of secrets manager sub * Fix the ui product sorting --- .../trial-initiation/billing.component.html | 52 +++++-- .../organization-plans.component.html | 144 ++++++++++++------ .../organization-plans.component.ts | 85 ++++++----- ...nization-subscription-cloud.component.html | 6 +- ...ganization-subscription-cloud.component.ts | 51 ++++--- .../sm-subscribe-standalone.component.ts | 4 +- .../shared/sm-subscribe.component.html | 4 +- .../billing/shared/sm-subscribe.component.ts | 14 +- .../models/response/organization.response.ts | 5 - .../billing/models/response/plan.response.ts | 137 +++++++++++------ .../models/response/subscription.response.ts | 4 +- libs/common/src/services/api.service.ts | 2 +- 12 files changed, 313 insertions(+), 195 deletions(-) diff --git a/apps/web/src/app/billing/accounts/trial-initiation/billing.component.html b/apps/web/src/app/billing/accounts/trial-initiation/billing.component.html index fe1f2b425d1..ba50a22b569 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/billing.component.html +++ b/apps/web/src/app/billing/accounts/trial-initiation/billing.component.html @@ -14,20 +14,48 @@

{{ "billingPlanLabel" | i18n } formControlName="plan" /> - {{ "annual" | i18n }} - - {{ - (selectablePlan.basePrice === 0 ? selectablePlan.seatPrice : selectablePlan.basePrice) - | currency : "$" - }} - /{{ "yr" | i18n }} + + {{ "annual" | i18n }} - + {{ + (selectablePlan.PasswordManager.basePrice === 0 + ? selectablePlan.PasswordManager.seatPrice + : selectablePlan.PasswordManager.basePrice + ) | currency : "$" + }} + /{{ "yr" | i18n }} + + + {{ "annual" | i18n }} - + {{ + (selectablePlan.SecretsManager.basePrice === 0 + ? selectablePlan.SecretsManager.seatPrice + : selectablePlan.SecretsManager.basePrice + ) | currency : "$" + }} + /{{ "yr" | i18n }} + - {{ "monthly" | i18n }} - - {{ - (selectablePlan.basePrice === 0 ? selectablePlan.seatPrice : selectablePlan.basePrice) - | currency : "$" - }} - /{{ "monthAbbr" | i18n }} + + {{ "monthly" | i18n }} - + {{ + (selectablePlan.PasswordManager.basePrice === 0 + ? selectablePlan.PasswordManager.seatPrice + : selectablePlan.PasswordManager.basePrice + ) | currency : "$" + }} + /{{ "monthAbbr" | i18n }} + + + {{ "monthly" | i18n }} - + {{ + (selectablePlan.SecretsManager.basePrice === 0 + ? selectablePlan.SecretsManager.seatPrice + : selectablePlan.SecretsManager.basePrice + ) | currency : "$" + }} + /{{ "monthAbbr" | i18n }} + diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index 87a8ad61627..6dd97b1eec7 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -68,23 +68,38 @@

{{ "chooseYourPlan" | i18n }}

• {{ "limitedUsers" | i18n : selectableProduct.maxUsers }}• {{ "limitedUsers" | i18n : selectableProduct.PasswordManager.maxSeats }} - • {{ "addShareLimitedUsers" | i18n : selectableProduct.maxUsers }}• {{ "addShareLimitedUsers" | i18n : selectableProduct.PasswordManager.maxSeats }} - • {{ "addShareUnlimitedUsers" | i18n }} - • {{ "limitedCollections" | i18n : selectableProduct.maxCollections }}• {{ "addShareUnlimitedUsers" | i18n }} - • {{ "addShareLimitedUsers" | i18n : selectableProduct.maxAdditionalSeats }}• + {{ + "limitedCollections" | i18n : selectableProduct.PasswordManager.maxCollections + }} + • + {{ + "addShareLimitedUsers" | i18n : selectableProduct.PasswordManager.maxAdditionalSeats + }} - • {{ "createUnlimitedCollections" | i18n }} - • {{ "gbEncryptedFileStorage" | i18n : selectableProduct.baseStorageGb + "GB" }}• + {{ + "gbEncryptedFileStorage" | i18n : selectableProduct.PasswordManager.baseStorageGb + "GB" + }} • {{ "controlAccessWithGroups" | i18n }} • {{ "trackAuditLogs" | i18n }} @@ -102,25 +117,40 @@

{{ "chooseYourPlan" | i18n }}

- - {{ selectableProduct.basePrice / 12 | currency : "$" }} /{{ "month" | i18n }}, - {{ "includesXUsers" | i18n : selectableProduct.baseSeats }} - + + {{ selectableProduct.PasswordManager.basePrice / 12 | currency : "$" }} /{{ + "month" | i18n + }}, + {{ "includesXUsers" | i18n : selectableProduct.PasswordManager.baseSeats }} + {{ ("additionalUsers" | i18n).toLowerCase() }} - {{ selectableProduct.seatPrice / 12 | currency : "$" }} /{{ "month" | i18n }} + {{ selectableProduct.PasswordManager.seatPrice / 12 | currency : "$" }} /{{ + "month" | i18n + }} - - {{ "costPerUser" | i18n : (selectableProduct.seatPrice / 12 | currency : "$") }} /{{ - "month" | i18n + + {{ + "costPerUser" | i18n : (selectableProduct.PasswordManager.seatPrice / 12 | currency : "$") }} + /{{ "month" | i18n }} {{ "freeForever" | i18n }}
- +

{{ "users" | i18n }}

@@ -139,7 +169,13 @@

{{ "users" | i18n }}

{{ "addons" | i18n }}

-
+
{{ "addons" | i18n }}

/> {{ "userSeatsAdditionalDesc" - | i18n : selectedPlan.baseSeats : (seatPriceMonthly(selectedPlan) | currency : "$") + | i18n + : selectedPlan.PasswordManager.baseSeats + : (seatPriceMonthly(selectedPlan) | currency : "$") }} @@ -178,7 +216,7 @@

{{ "addons" | i18n }}

-
+
{{ "summary" | i18n }}
+ + +
+
+
+ {{ "typePasskey" | i18n }} + {{ "dateCreated" | i18n }} + {{ cipher.login.fido2Credentials[0].creationDate | date : "short" }} +
+
+
+
-
{{ "verificationCodeTotp" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index 29027b33505..b29814ed559 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -1,7 +1,7 @@ import { Location } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component"; @@ -29,6 +29,10 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; +import { + BrowserFido2UserInterfaceSession, + fido2PopoutSessionData$, +} from "../../../fido2/browser-fido2-user-interface.service"; const BroadcasterSubscriptionId = "ChildViewComponent"; @@ -57,6 +61,7 @@ export class ViewComponent extends BaseViewComponent { loadPageDetailsTimeout: number; inPopout = false; cipherType = CipherType; + private fido2PopoutSessionData$ = fido2PopoutSessionData$(); private destroy$ = new Subject(); @@ -301,7 +306,14 @@ export class ViewComponent extends BaseViewComponent { return false; } - close() { + async close() { + // Would be refactored after rework is done on the windows popout service + const sessionData = await firstValueFrom(this.fido2PopoutSessionData$); + if (this.inPopout && sessionData.isFido2Session) { + BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId); + return; + } + if (this.inPopout && this.senderTabId) { BrowserApi.focusTab(this.senderTabId); window.close(); diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 834647231f3..10f9b5b2b61 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -160,6 +160,8 @@ const mainConfig = { "content/notificationBar": "./src/autofill/content/notification-bar.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", "content/message_handler": "./src/autofill/content/message_handler.ts", + "content/fido2/content-script": "./src/vault/fido2/content/content-script.ts", + "content/fido2/page-script": "./src/vault/fido2/content/page-script.ts", "notification/bar": "./src/autofill/notification/bar.ts", "encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts", }, diff --git a/apps/desktop/src/images/bwi-passkey.png b/apps/desktop/src/images/bwi-passkey.png new file mode 100644 index 0000000000000000000000000000000000000000..702be33446ef4adde39ab26e20b6e0e311b70594 GIT binary patch literal 1078 zcmV-61j+k}P)5UsHz5Ul0~8BP$h%K{1e1UM&%eS!=N#BbqY!N+2OZvdPi~?=GP`&M z5(vn9<7%ff8a&gsk$6($9;UnNbyam$^@w~>crqb2H#e)E=he}^j282}7_GOzzke=M z!kO64&Q1eAEwo_FFcOTWA?QSGcX#*X_4PG0&*&!_(nnv0WGmMDi-(7YW~b8`h$B7~ z9hf53e|~;mZ@1gM^f+j=f^jDxoS1rHVS$8e;)p^TQ}dm&525FWhlh>4IosRY5s0;P z&K~AO;(%OYQ55-)kB>L#1~eT6EJ;CNLqG9TM;qMV-!HkCw3Ighq_}Xv{>#gYlS$_0 z=9(mh=EJ0+%iLUIz#9KRTRr$xEDnISNvfxhtKxuMV(^ndyO%Nnu%Sy`r{Xd%mlzf{ z3pyWpC1mFLF7t|*GM;4NX;DODF@Lf1&oUV*x=!C^QW1?A->QdUIEmJh!-I{@mKYYi z&@PXBOB{eW_Cqw(AX7Ny&2rv=tE;QuD=RBYJXL64(T}gMuW$3_F*V^Q*%Wi;(a}*` z98koRGCw~bTE2D$p{*QJ)KphV#R$yn;)uf41?sP$R4R3?xE7;zV4_5Oc<1wgfCy`} zs*Yi(s2v;}IQf?oF+;9gE>jahf#Cy?UUtnE28B#Xu)=U4Mfe<#a_PHFfJ}|qWiy%y z5q3%$z;AfU`o{MNniJ2QyJnf3h@riji}t)UAuQgqd?X8nOq8+K0Hm8Jn=ZtKn4YAL zO+fUx1Jpz%rG*Giy(c-?+S+P>;E8BTQ_c1A<|V{XZPIKk>df(OW=*CLR;7kuQ6Z@l zJ$QP0svRF6_j8FsTX&)rDvQB!X37W~GXZ2bVCGSFC}}+(@F9;B!S~dt%oq&Zu-xbb zdwAgPtE^DLI4PZ(tP$<~aU^Me%$Z3kWV;8}TnCSi2aWxap>rqyNCnY89DG@(fT>9A ziwI@do6c~Lhym7D*2zQ+Xvn`HTG`mxa4LyyP~hU?;tg8Y5M*-4#&6R37V<}ZD9Pi6 zH@U?av*_)5Gzdf|Dyh@yi{$V>CUc&C%8EdauYSoe;T!phkpdxK3D{#QO4LS#7Ah$!fTYg&g3cd;1OuQ`T!N50DFEKW6G3__mtp4k zhWV6fUv&PfLP|J&XZnIWJsqbBi&R&Nk)j47HYJ!4;O21|W&}ubEmK^&H1bqf)e`pR zCnO!mZy13QjF^@g!7*--7M4DZ{QQ8gL78D`
+ +
+ {{ "typePasskey" | i18n }} + {{ "dateCreated" | i18n }} + {{ cipher.login.fido2Credentials[0].creationDate | date : "short" }} +
+
+ +
+ {{ "typePasskey" | i18n }} + {{ "dateCreated" | i18n }} + {{ cipher.login.fido2Credentials[0].creationDate | date : "short" }} +
; showPasswordless = false; - private destroy$ = new Subject(); - constructor( devicesApiService: DevicesApiServiceAbstraction, appIdService: AppIdService, @@ -146,11 +145,6 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest } } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - async goAfterLogIn() { const masterPassword = this.formGroup.value.masterPassword; diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-add-edit.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-add-edit.component.ts index fdb72518b19..0e82a5c76ae 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-add-edit.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-add-edit.component.ts @@ -1,3 +1,4 @@ +import { DatePipe } from "@angular/common"; import { Component } from "@angular/core"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -47,7 +48,8 @@ export class EmergencyAddEditComponent extends BaseAddEditComponent { organizationService: OrganizationService, logService: LogService, sendApiService: SendApiService, - dialogService: DialogService + dialogService: DialogService, + datePipe: DatePipe ) { super( cipherService, @@ -66,7 +68,8 @@ export class EmergencyAddEditComponent extends BaseAddEditComponent { logService, passwordRepromptService, sendApiService, - dialogService + dialogService, + datePipe ); } diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 934062fa699..0564512ac32 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -100,6 +100,7 @@ {{ "launch" | i18n }} +
+ +
+
+ +
+ +
+
+
+
diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 74e7a9e6e30..cf998c5c8f6 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -1,3 +1,4 @@ +import { DatePipe } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -18,7 +19,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -42,6 +43,15 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On protected totpInterval: number; protected override componentName = "app-vault-add-edit"; + get fido2CredentialCreationDateValue(): string { + const dateCreated = this.i18nService.t("dateCreated"); + const creationDate = this.datePipe.transform( + this.cipher?.login?.fido2Credentials?.[0]?.creationDate, + "short" + ); + return `${dateCreated} ${creationDate}`; + } + constructor( cipherService: CipherService, folderService: FolderService, @@ -59,7 +69,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On logService: LogService, passwordRepromptService: PasswordRepromptService, sendApiService: SendApiService, - dialogService: DialogService + dialogService: DialogService, + private datePipe: DatePipe ) { super( cipherService, @@ -131,7 +142,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On } } - launch(uri: LoginUriView) { + launch(uri: Launchable) { if (!uri.canLaunch) { return; } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 3b8d60d263b..aae45d27e25 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -721,6 +721,18 @@ export class VaultComponent implements OnInit, OnDestroy { } async cloneCipher(cipher: CipherView) { + if (cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return false; + } + } + const component = await this.editCipher(cipher); component.cloneMode = true; } diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index c8a37c087ad..93be56f1a0b 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -1,3 +1,4 @@ +import { DatePipe } from "@angular/common"; import { Component } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -49,7 +50,8 @@ export class AddEditComponent extends BaseAddEditComponent { passwordRepromptService: PasswordRepromptService, organizationService: OrganizationService, sendApiService: SendApiService, - dialogService: DialogService + dialogService: DialogService, + datePipe: DatePipe ) { super( cipherService, @@ -68,7 +70,8 @@ export class AddEditComponent extends BaseAddEditComponent { logService, passwordRepromptService, sendApiService, - dialogService + dialogService, + datePipe ); } diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index dcdd73da3c4..22aa277fc22 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -644,6 +644,18 @@ export class VaultComponent implements OnInit, OnDestroy { } async cloneCipher(cipher: CipherView) { + if (cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return false; + } + } + const collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter( (c) => !c.readOnly && c.id != Unassigned ); diff --git a/apps/web/src/images/bwi-passkey.png b/apps/web/src/images/bwi-passkey.png new file mode 100644 index 0000000000000000000000000000000000000000..702be33446ef4adde39ab26e20b6e0e311b70594 GIT binary patch literal 1078 zcmV-61j+k}P)5UsHz5Ul0~8BP$h%K{1e1UM&%eS!=N#BbqY!N+2OZvdPi~?=GP`&M z5(vn9<7%ff8a&gsk$6($9;UnNbyam$^@w~>crqb2H#e)E=he}^j282}7_GOzzke=M z!kO64&Q1eAEwo_FFcOTWA?QSGcX#*X_4PG0&*&!_(nnv0WGmMDi-(7YW~b8`h$B7~ z9hf53e|~;mZ@1gM^f+j=f^jDxoS1rHVS$8e;)p^TQ}dm&525FWhlh>4IosRY5s0;P z&K~AO;(%OYQ55-)kB>L#1~eT6EJ;CNLqG9TM;qMV-!HkCw3Ighq_}Xv{>#gYlS$_0 z=9(mh=EJ0+%iLUIz#9KRTRr$xEDnISNvfxhtKxuMV(^ndyO%Nnu%Sy`r{Xd%mlzf{ z3pyWpC1mFLF7t|*GM;4NX;DODF@Lf1&oUV*x=!C^QW1?A->QdUIEmJh!-I{@mKYYi z&@PXBOB{eW_Cqw(AX7Ny&2rv=tE;QuD=RBYJXL64(T}gMuW$3_F*V^Q*%Wi;(a}*` z98koRGCw~bTE2D$p{*QJ)KphV#R$yn;)uf41?sP$R4R3?xE7;zV4_5Oc<1wgfCy`} zs*Yi(s2v;}IQf?oF+;9gE>jahf#Cy?UUtnE28B#Xu)=U4Mfe<#a_PHFfJ}|qWiy%y z5q3%$z;AfU`o{MNniJ2QyJnf3h@riji}t)UAuQgqd?X8nOq8+K0Hm8Jn=ZtKn4YAL zO+fUx1Jpz%rG*Giy(c-?+S+P>;E8BTQ_c1A<|V{XZPIKk>df(OW=*CLR;7kuQ6Z@l zJ$QP0svRF6_j8FsTX&)rDvQB!X37W~GXZ2bVCGSFC}}+(@F9;B!S~dt%oq&Zu-xbb zdwAgPtE^DLI4PZ(tP$<~aU^Me%$Z3kWV;8}TnCSi2aWxap>rqyNCnY89DG@(fT>9A ziwI@do6c~Lhym7D*2zQ+Xvn`HTG`mxa4LyyP~hU?;tg8Y5M*-4#&6R37V<}ZD9Pi6 zH@U?av*_)5Gzdf|Dyh@yi{$V>CUc&C%8EdauYSoe;T!phkpdxK3D{#QO4LS#7Ah$!fTYg&g3cd;1OuQ`T!N50DFEKW6G3__mtp4k zhWV6fUv&PfLP|J&XZnIWJsqbBi&R&Nk)j47HYJ!4;O21|W&}ubEmK^&H1bqf)e`pR zCnO!mZy13QjF^@g!7*--7M4DZ{QQ8gL78D`(); + get loggedEmail() { return this.formGroup.value.email; } @@ -83,14 +86,17 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } async ngOnInit() { - this.route?.queryParams.subscribe((params) => { - if (params != null) { - const queryParamsEmail = params["email"]; - if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { - this.formGroup.get("email").setValue(queryParamsEmail); - this.loginService.setEmail(queryParamsEmail); - this.paramEmailSet = true; - } + this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { + if (!params) { + return; + } + + const queryParamsEmail = params.email; + + if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { + this.formGroup.get("email").setValue(queryParamsEmail); + this.loginService.setEmail(queryParamsEmail); + this.paramEmailSet = true; } }); let email = this.loginService.getEmail(); @@ -109,6 +115,11 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit this.formGroup.get("rememberEmail")?.setValue(rememberEmail); } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + async submit(showToast = true) { const data = this.formGroup.value; diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.svg b/libs/angular/src/scss/bwicons/fonts/bwi-font.svg index 6ea08093176..e815389126c 100644 --- a/libs/angular/src/scss/bwicons/fonts/bwi-font.svg +++ b/libs/angular/src/scss/bwicons/fonts/bwi-font.svg @@ -53,7 +53,7 @@ - + @@ -71,7 +71,7 @@ - + @@ -131,9 +131,9 @@ - + - + @@ -185,6 +185,8 @@ + + diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf b/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf index f3d2459393d5d18d8cd136becbdf36a9636c8948..f9b63283e0465ea0285ac022a2aa1540746d3d60 100644 GIT binary patch delta 1180 zcmZ8hOKcle6usAvk<579CQV%D*GNDjS{gpZ#27bOG}KKa z2o+#2VG(e#DoYg$3lNGBu<;_5K!AkACXuMhrptEA0#%^y8izY}BZa;<@7;Iq=bfi> zrFp#P*uU$js^xzGFaQ7-SM%3OWIcqp$abw>e*e33du$V)(}G8a8UrV;}A|*YJmlntN}#xjxw&sfm-XHvcS) zuhuT@FE@*Oh_&m5SDHWXpIabu%$jbDMPnLmW~!Yijf!JbK%W#Lw}z_HF0l*Z*l5boql9_Mq**p& z&UPikGNL4=rtmv47!>`XkpF8VD+qI1(lC%7blW~iPK&$ zBF|3^4keu~r_;r_gWLrSGrkjpt0&dz(6fWw_SMCuQfX=N>gw;WPi3;1kru~`^FwoQ zA-*wJ9$Jw2Vuw%+;W4s~7eo<^0D~q_f%1>*Pqsa*q4)e+}%%AogRsy=6uXE72Gc z8D2eQ@?onvVTJK~1yO1<^9t)mmgz?dwL4{}D7_%up~8pVEZ^J9v)utf2vmD{3-H@d z?DuU%o|UH^2N_a0sg(^wOV0WQb3xALa@iL2Jtxp0y~+a$m+(Rp&UY zaGb8HAIH<_xDBiwOD-QfY+XLg`dmUF(E8wr^2o|hs7Ic5*v{$6d2P-w2>#h*QZKm9 zp1PCEW^-Qd#VoFzI_qkEbtg`v)ldgmx9rL0Y!we@sA_a&g%mS9+(9NCw_XuX@45qc z!5;YbhWXx`+{7brIjGA3_dmb+?~$!nLTKVn{dNdHLaduI&fB27UXk(d9_V(!;UfqA x2I^a3{KE-Up4pvI@MY|?efn2bslHaN(nUyX$Lp%bi delta 330 zcmX?eiRI5}mI=!B?-&>uWf&M3V$u_f3xKo$kY53$Inr|~(|#27Ndfr{42-gOGEx&$ z6v|FsV_=kz0m_?Y00mg~u<`==Q-FMxjNFonNg-mtf&3i|3`{d}@{<#%%nFoZU{neK zn&Fq5SW&>RlUaa)QF#GSy+U4MZmRMGF_V8lfdfDdG6ng?B@E0!DMr;h6L(BzoHyBr zQJQh<<_yMlHj}H8BRA*UajQ;FI}@?_QnF3@WHGz&&8epu87K3d4&S`@Oo;#XPM)8Ke9euzcVqgT~Wz2jGLLj3Un5G+QG74`OR%f(i F0sx$8Tn7LE diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff index c17abc55a63d925594ee5ed6b616f6df32364695..1e57b1aab32302c3438d584d5f9056158fb1c5b9 100644 GIT binary patch delta 1251 zcmZ8h-A^1<6hCJ_W`Hd_yF0Tp8kXJJ8Q2d#c2n5d*%k`hr7V!B5UWM8u272bQ0$fx zi$+}%U-SW8BxalXvQcA*Q4__47)|t{X-tZ-(SM-E7cr)3G<}m@&mADa&YjsS}UmMTo0RaHx*TH4k#{etGBPg$TMN5Cr&s;6i;!Rp7saF*!fQ*vED#B(w~=qb@tspxSX^EqZWO>)Ww`N_(s$|d zEXB3|L3=*Q(0=!~PZnoZihv!_eo9s?d~5f-G_yDd7(Gvk0S3LCsueF^U8cjYtpEE# zYjyX2eXm}pa7x;R-FudY_1_hTyuI}`e$l@%b$fE_n}O!`jSBA*?(8Ekyfl80uO9^xUN5334t8`V zoi3--#kqsRISg~YV;%D+^r6tB9m3j`@rhz_V*JYdt>*@_xome$5ap51`~}44^QF!) zMV#9gl!G{=EuGny+m}AGr1{LYCNIDEE@=*6cZ$?}1Mzq=5s&pTZ9C^^aC2qky-jMn z(Cm&pf(uNJuvf-O63ldAYdDU0K6iQsF`~9m!eH!hTpe|B))v+_d8MW0WH?M|u2T16whOt!TBk#OYxz5+xzSn@XEv zm^CM?ael8PE0(gT@owa~R-`4XQ+8UV6NLAr@O~pNHaClWV?dGu=*hXw?Jh?uQB6*;H~Qk@riIe>j;;&ALWU-YMjA z1+VZ#4yR9!x@sTYiP2T7>TSGR_2de+RgYlkdSrT~>khr?JKM&YxwiJXJ2!KJ4eUX?KHk_wxHaubb4=`i?X#fBK delta 401 zcmbPnndQV87O`@FH#Y`G1_lO3H4g@E5Uu)$fpL1HCZpIy9rgNm>50V!K*1wG84f5G zNYANE1B#ttV3fTB!as`oq%u+yQy3WKR{+(Rfv`f^$!i%vL7>u2-~a!C%7L0*GQ4~T<}v<%EY2dfb@LX+ zbvBcaB}Z=FZO5%TdDEGQ&2lL=>64S}!Z&X`&B!=8>U8*K&a)x@+ZW0+1~bc42D=!> z^V@u7;08Jt2p+IImcr=i0?Lf$jLW8bC^ObEGE6_N%;?UP|F1wj uoYOf}8P9LOugVz2DBjSwqwgJ1J1D4^G4nA9fsA5c0tK}2_IPzhOC|v5?{A3! diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 index 1685db245468fc1b9623fe389baac5173320a9f8..88036b7b3e8eda7d55afaceea4beb95b02821c88 100644 GIT binary patch literal 33420 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 (c as any).checked).map((c) => c.id); } - // Clear current Cipher Id to trigger "Add" cipher flow + // Clear current Cipher Id if exists to trigger "Add" cipher flow if (this.cloneMode) { this.cipher.id = null; } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 781034aa9f3..90032617b46 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -29,10 +29,10 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; 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 { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -62,6 +62,7 @@ export class ViewComponent implements OnDestroy, OnInit { fieldType = FieldType; checkPasswordPromise: Promise; folder: FolderView; + cipherType = CipherType; private totpInterval: any; private previousCipherId: string; @@ -156,6 +157,18 @@ export class ViewComponent implements OnDestroy, OnInit { } async clone() { + if (this.cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return false; + } + } + if (await this.promptPassword()) { this.onCloneCipher.emit(this.cipher); return true; @@ -295,7 +308,7 @@ export class ViewComponent implements OnDestroy, OnInit { } } - launch(uri: LoginUriView, cipherId?: string) { + launch(uri: Launchable, cipherId?: string) { if (!uri.canLaunch) { return; } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index cc0873351b8..398d63fe964 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -1,6 +1,7 @@ export enum FeatureFlag { DisplayEuEnvironmentFlag = "display-eu-environment", DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning", + Fido2VaultCredentials = "fido2-vault-credentials", TrustedDeviceEncryption = "trusted-device-encryption", PasswordlessLogin = "passwordless-login", AutofillV2 = "autofill-v2", diff --git a/libs/common/src/models/api/login.api.ts b/libs/common/src/models/api/login.api.ts index 82e28dd0a35..934d2e99b5b 100644 --- a/libs/common/src/models/api/login.api.ts +++ b/libs/common/src/models/api/login.api.ts @@ -1,3 +1,6 @@ +import { JsonObject } from "type-fest"; + +import { Fido2CredentialApi } from "../../vault/api/fido2-credential.api"; import { BaseResponse } from "../response/base.response"; import { LoginUriApi } from "./login-uri.api"; @@ -9,6 +12,7 @@ export class LoginApi extends BaseResponse { passwordRevisionDate: string; totp: string; autofillOnPageLoad: boolean; + fido2Credentials?: Fido2CredentialApi[]; constructor(data: any = null) { super(data); @@ -25,5 +29,12 @@ export class LoginApi extends BaseResponse { if (uris != null) { this.uris = uris.map((u: any) => new LoginUriApi(u)); } + + const fido2Credentials = this.getResponseProperty("Fido2Credentials"); + if (fido2Credentials != null) { + this.fido2Credentials = fido2Credentials.map( + (key: JsonObject) => new Fido2CredentialApi(key) + ); + } } } diff --git a/libs/common/src/models/export/fido2-credential.export.ts b/libs/common/src/models/export/fido2-credential.export.ts new file mode 100644 index 00000000000..258699c8daf --- /dev/null +++ b/libs/common/src/models/export/fido2-credential.export.ts @@ -0,0 +1,125 @@ +import { EncString } from "../../platform/models/domain/enc-string"; +import { Fido2Credential } from "../../vault/models/domain/fido2-credential"; +import { Fido2CredentialView } from "../../vault/models/view/fido2-credential.view"; + +/** + * Represents format of Fido2 Credentials in JSON exports. + */ +export class Fido2CredentialExport { + /** + * Generates a template for Fido2CredentialExport + * @returns Instance of Fido2CredentialExport with predefined values. + */ + static template(): Fido2CredentialExport { + const req = new Fido2CredentialExport(); + req.credentialId = "keyId"; + req.keyType = "keyType"; + req.keyAlgorithm = "keyAlgorithm"; + req.keyCurve = "keyCurve"; + req.keyValue = "keyValue"; + req.rpId = "rpId"; + req.userHandle = "userHandle"; + req.counter = "counter"; + req.rpName = "rpName"; + req.userDisplayName = "userDisplayName"; + req.discoverable = "false"; + req.creationDate = null; + return req; + } + + /** + * Converts a Fido2CredentialExport object to its view representation. + * @param req - The Fido2CredentialExport object to be converted. + * @param view - (Optional) The Fido2CredentialView object to popualte with Fido2CredentialExport data + * @returns Fido2CredentialView - The populated view, or a new instance if none was provided. + */ + static toView(req: Fido2CredentialExport, view = new Fido2CredentialView()) { + view.credentialId = req.credentialId; + view.keyType = req.keyType as "public-key"; + view.keyAlgorithm = req.keyAlgorithm as "ECDSA"; + view.keyCurve = req.keyCurve as "P-256"; + view.keyValue = req.keyValue; + view.rpId = req.rpId; + view.userHandle = req.userHandle; + view.counter = parseInt(req.counter); + view.rpName = req.rpName; + view.userDisplayName = req.userDisplayName; + view.discoverable = req.discoverable === "true"; + view.creationDate = new Date(req.creationDate); + return view; + } + + /** + * Converts a Fido2CredentialExport object to its domain representation. + * @param req - The Fido2CredentialExport object to be converted. + * @param domain - (Optional) The Fido2Credential object to popualte with Fido2CredentialExport data + * @returns Fido2Credential - The populated domain, or a new instance if none was provided. + */ + static toDomain(req: Fido2CredentialExport, domain = new Fido2Credential()) { + domain.credentialId = req.credentialId != null ? new EncString(req.credentialId) : null; + domain.keyType = req.keyType != null ? new EncString(req.keyType) : null; + domain.keyAlgorithm = req.keyAlgorithm != null ? new EncString(req.keyAlgorithm) : null; + domain.keyCurve = req.keyCurve != null ? new EncString(req.keyCurve) : null; + domain.keyValue = req.keyValue != null ? new EncString(req.keyValue) : null; + domain.rpId = req.rpId != null ? new EncString(req.rpId) : null; + domain.userHandle = req.userHandle != null ? new EncString(req.userHandle) : null; + domain.counter = req.counter != null ? new EncString(req.counter) : null; + domain.rpName = req.rpName != null ? new EncString(req.rpName) : null; + domain.userDisplayName = + req.userDisplayName != null ? new EncString(req.userDisplayName) : null; + domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null; + domain.creationDate = req.creationDate; + return domain; + } + + credentialId: string; + keyType: string; + keyAlgorithm: string; + keyCurve: string; + keyValue: string; + rpId: string; + userHandle: string; + counter: string; + rpName: string; + userDisplayName: string; + discoverable: string; + creationDate: Date; + + /** + * Constructs a new Fid2CredentialExport instance. + * + * @param o - The credential storing the data being exported. When not provided, an empty export is created instead. + */ + constructor(o?: Fido2CredentialView | Fido2Credential) { + if (o == null) { + return; + } + + if (o instanceof Fido2CredentialView) { + this.credentialId = o.credentialId; + this.keyType = o.keyType; + this.keyAlgorithm = o.keyAlgorithm; + this.keyCurve = o.keyCurve; + this.keyValue = o.keyValue; + this.rpId = o.rpId; + this.userHandle = o.userHandle; + this.counter = String(o.counter); + this.rpName = o.rpName; + this.userDisplayName = o.userDisplayName; + this.discoverable = String(o.discoverable); + } else { + this.credentialId = o.credentialId?.encryptedString; + this.keyType = o.keyType?.encryptedString; + this.keyAlgorithm = o.keyAlgorithm?.encryptedString; + this.keyCurve = o.keyCurve?.encryptedString; + this.keyValue = o.keyValue?.encryptedString; + this.rpId = o.rpId?.encryptedString; + this.userHandle = o.userHandle?.encryptedString; + this.counter = o.counter?.encryptedString; + this.rpName = o.rpName?.encryptedString; + this.userDisplayName = o.userDisplayName?.encryptedString; + this.discoverable = o.discoverable?.encryptedString; + } + this.creationDate = o.creationDate; + } +} diff --git a/libs/common/src/models/export/index.ts b/libs/common/src/models/export/index.ts index b92c68d8143..0b338572207 100644 --- a/libs/common/src/models/export/index.ts +++ b/libs/common/src/models/export/index.ts @@ -9,3 +9,4 @@ export { FolderExport } from "./folder.export"; export { IdentityExport } from "./identity.export"; export { LoginUriExport } from "./login-uri.export"; export { SecureNoteExport } from "./secure-note.export"; +export { Fido2CredentialExport } from "./fido2-credential.export"; diff --git a/libs/common/src/models/export/login.export.ts b/libs/common/src/models/export/login.export.ts index 7a22b12537f..a5d9348c2ca 100644 --- a/libs/common/src/models/export/login.export.ts +++ b/libs/common/src/models/export/login.export.ts @@ -2,6 +2,7 @@ import { EncString } from "../../platform/models/domain/enc-string"; import { Login as LoginDomain } from "../../vault/models/domain/login"; import { LoginView } from "../../vault/models/view/login.view"; +import { Fido2CredentialExport } from "./fido2-credential.export"; import { LoginUriExport } from "./login-uri.export"; export class LoginExport { @@ -11,6 +12,7 @@ export class LoginExport { req.username = "jdoe"; req.password = "myp@ssword123"; req.totp = "JBSWY3DPEHPK3PXP"; + req.fido2Credentials = [Fido2CredentialExport.template()]; return req; } @@ -21,6 +23,9 @@ export class LoginExport { view.username = req.username; view.password = req.password; view.totp = req.totp; + if (req.fido2Credentials != null) { + view.fido2Credentials = req.fido2Credentials.map((key) => Fido2CredentialExport.toView(key)); + } return view; } @@ -31,6 +36,8 @@ export class LoginExport { domain.username = req.username != null ? new EncString(req.username) : null; domain.password = req.password != null ? new EncString(req.password) : null; domain.totp = req.totp != null ? new EncString(req.totp) : null; + // Fido2credentials are currently not supported for exports. + return domain; } @@ -38,6 +45,7 @@ export class LoginExport { username: string; password: string; totp: string; + fido2Credentials: Fido2CredentialExport[] = []; constructor(o?: LoginView | LoginDomain) { if (o == null) { @@ -52,6 +60,10 @@ export class LoginExport { } } + if (o.fido2Credentials != null) { + this.fido2Credentials = o.fido2Credentials.map((key) => new Fido2CredentialExport(key)); + } + if (o instanceof LoginView) { this.username = o.username; this.password = o.password; diff --git a/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts new file mode 100644 index 00000000000..5a406aeb14c --- /dev/null +++ b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -0,0 +1,143 @@ +/** + * This class represents an abstraction of the WebAuthn Authenticator model as described by W3C: + * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model + * + * The authenticator provides key management and cryptographic signatures. + */ +export abstract class Fido2AuthenticatorService { + /** + * Create and save a new credential as described in: + * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred + * + * @param params Parameters for creating a new credential + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the new credential and an attestation signature. + **/ + makeCredential: ( + params: Fido2AuthenticatorMakeCredentialsParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; + + /** + * Generate an assertion using an existing credential as describe in: + * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion + * + * @param params Parameters for generating an assertion + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the asserted credential and an assertion signature. + */ + getAssertion: ( + params: Fido2AuthenticatorGetAssertionParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; +} + +export enum Fido2AlgorithmIdentifier { + ES256 = -7, + RS256 = -257, +} + +export enum Fido2AutenticatorErrorCode { + Unknown = "UnknownError", + NotSupported = "NotSupportedError", + InvalidState = "InvalidStateError", + NotAllowed = "NotAllowedError", + Constraint = "ConstraintError", +} + +export class Fido2AutenticatorError extends Error { + constructor(readonly errorCode: Fido2AutenticatorErrorCode) { + super(errorCode); + } +} + +export interface PublicKeyCredentialDescriptor { + id: BufferSource; + transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + type: "public-key"; +} + +/** + * Parameters for {@link Fido2AuthenticatorService.makeCredential} + * + * This interface represents the input parameters described in + * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred + */ +export interface Fido2AuthenticatorMakeCredentialsParams { + /** The hash of the serialized client data, provided by the client. */ + hash: BufferSource; + /** The Relying Party's PublicKeyCredentialRpEntity. */ + rpEntity: { + name: string; + id?: string; + }; + /** The user account’s PublicKeyCredentialUserEntity, containing the user handle given by the Relying Party. */ + userEntity: { + id: BufferSource; + name?: string; + displayName?: string; + icon?: string; + }; + /** A sequence of pairs of PublicKeyCredentialType and public key algorithms (COSEAlgorithmIdentifier) requested by the Relying Party. This sequence is ordered from most preferred to least preferred. The authenticator makes a best-effort to create the most preferred credential that it can. */ + credTypesAndPubKeyAlgs: { + alg: number; + type: "public-key"; // not used + }[]; + /** An OPTIONAL list of PublicKeyCredentialDescriptor objects provided by the Relying Party with the intention that, if any of these are known to the authenticator, it SHOULD NOT create a new credential. excludeCredentialDescriptorList contains a list of known credentials. */ + excludeCredentialDescriptorList?: PublicKeyCredentialDescriptor[]; + /** A map from extension identifiers to their authenticator extension inputs, created by the client based on the extensions requested by the Relying Party, if any. */ + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + uvm?: boolean; + }; + /** A Boolean value that indicates that individually-identifying attestation MAY be returned by the authenticator. */ + enterpriseAttestationPossible?: boolean; // Ignored by bitwarden at the moment + /** The effective resident key requirement for credential creation, a Boolean value determined by the client. Resident is synonymous with discoverable. */ + requireResidentKey: boolean; + requireUserVerification: boolean; + /** Forwarded to user interface */ + fallbackSupported: boolean; + /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ + // requireUserPresence: true; // Always required +} + +export interface Fido2AuthenticatorMakeCredentialResult { + credentialId: BufferSource; + attestationObject: BufferSource; + authData: BufferSource; + publicKeyAlgorithm: number; +} + +/** + * Parameters for {@link Fido2AuthenticatorService.getAssertion} + + * This interface represents the input parameters described in + * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion + */ +export interface Fido2AuthenticatorGetAssertionParams { + /** The caller’s RP ID, as determined by the user agent and the client. */ + rpId: string; + /** The hash of the serialized client data, provided by the client. */ + hash: BufferSource; + allowCredentialDescriptorList: PublicKeyCredentialDescriptor[]; + /** The effective user verification requirement for assertion, a Boolean value provided by the client. */ + requireUserVerification: boolean; + /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ + // requireUserPresence: boolean; // Always required + extensions: unknown; + /** Forwarded to user interface */ + fallbackSupported: boolean; +} + +export interface Fido2AuthenticatorGetAssertionResult { + selectedCredential: { + id: Uint8Array; + userHandle?: Uint8Array; + }; + authenticatorData: Uint8Array; + signature: Uint8Array; +} diff --git a/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts new file mode 100644 index 00000000000..fca73c8d99e --- /dev/null +++ b/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts @@ -0,0 +1,174 @@ +export const UserRequestedFallbackAbortReason = "UserRequestedFallback"; + +export type UserVerification = "discouraged" | "preferred" | "required"; + +/** + * This class represents an abstraction of the WebAuthn Client as described by W3C: + * https://www.w3.org/TR/webauthn-3/#webauthn-client + * + * The WebAuthn Client is an intermediary entity typically implemented in the user agent + * (in whole, or in part). Conceptually, it underlies the Web Authentication API and embodies + * the implementation of the Web Authentication API's operations. + * + * It is responsible for both marshalling the inputs for the underlying authenticator operations, + * and for returning the results of the latter operations to the Web Authentication API's callers. + */ +export abstract class Fido2ClientService { + /** + * Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. + * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential + * + * @param params The parameters for the credential creation operation. + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the new credential. + */ + createCredential: ( + params: CreateCredentialParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; + + /** + * Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the user’s consent. + * Relying Party script can optionally specify some criteria to indicate what credential sources are acceptable to it. + * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-getAssertion + * + * @param params The parameters for the credential assertion operation. + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the asserted credential. + */ + assertCredential: ( + params: AssertCredentialParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; + + isFido2FeatureEnabled: () => Promise; +} + +/** + * Parameters for creating a new credential. + */ +export interface CreateCredentialParams { + /** The Relaying Parties origin, see: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin */ + origin: string; + /** + * A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors. + * It is false if caller is cross-origin. + * */ + sameOriginWithAncestors: boolean; + /** The Relying Party's preference for attestation conveyance */ + attestation?: "direct" | "enterprise" | "indirect" | "none"; + /** The Relying Party's requirements of the authenticator used in the creation of the credential. */ + authenticatorSelection?: { + // authenticatorAttachment?: AuthenticatorAttachment; // not used + requireResidentKey?: boolean; + residentKey?: "discouraged" | "preferred" | "required"; + userVerification?: UserVerification; + }; + /** Challenge intended to be used for generating the newly created credential's attestation object. */ + challenge: string; // b64 encoded + /** + * This member is intended for use by Relying Parties that wish to limit the creation of multiple credentials for + * the same account on a single authenticator. The client is requested to return an error if the new credential would + * be created on an authenticator that also contains one of the credentials enumerated in this parameter. + * */ + excludeCredentials?: { + id: string; // b64 encoded + transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + type: "public-key"; + }[]; + /** + * This member contains additional parameters requesting additional processing by the client and authenticator. + * Not currently supported. + **/ + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + uvm?: boolean; + }; + /** + * This member contains information about the desired properties of the credential to be created. + * The sequence is ordered from most preferred to least preferred. + * The client makes a best-effort to create the most preferred credential that it can. + */ + pubKeyCredParams: PublicKeyCredentialParam[]; + /** Data about the Relying Party responsible for the request. */ + rp: { + id?: string; + name: string; + }; + /** Data about the user account for which the Relying Party is requesting attestation. */ + user: { + id: string; // b64 encoded + displayName: string; + }; + /** Forwarded to user interface */ + fallbackSupported: boolean; + /** + * This member specifies a time, in milliseconds, that the caller is willing to wait for the call to complete. + * This is treated as a hint, and MAY be overridden by the client. + **/ + timeout?: number; +} + +/** + * The result of creating a new credential. + */ +export interface CreateCredentialResult { + credentialId: string; + clientDataJSON: string; + attestationObject: string; + authData: string; + publicKeyAlgorithm: number; + transports: string[]; +} + +/** + * Parameters for asserting a credential. + */ +export interface AssertCredentialParams { + allowedCredentialIds: string[]; + rpId: string; + origin: string; + challenge: string; + userVerification?: UserVerification; + timeout: number; + sameOriginWithAncestors: boolean; + fallbackSupported: boolean; +} + +/** + * The result of asserting a credential. + */ +export interface AssertCredentialResult { + credentialId: string; + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string; +} + +/** + * A description of a key type and algorithm. + * + * @example { + * alg: -7, // ES256 + * type: "public-key" + * } + */ +export interface PublicKeyCredentialParam { + alg: number; + type: "public-key"; +} + +/** + * Error thrown when the user requests a fallback to the browser's built-in WebAuthn implementation. + */ +export class FallbackRequestedError extends Error { + readonly fallbackRequested = true; + constructor() { + super("FallbackRequested"); + } +} diff --git a/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts new file mode 100644 index 00000000000..fe15aec0fdc --- /dev/null +++ b/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -0,0 +1,103 @@ +/** + * Parameters used to ask the user to confirm the creation of a new credential. + */ +export interface NewCredentialParams { + /** + * The name of the credential. + */ + credentialName: string; + + /** + * The name of the user. + */ + userName: string; + + /** + * Whether or not the user must be verified before completing the operation. + */ + userVerification: boolean; +} + +/** + * Parameters used to ask the user to pick a credential from a list of existing credentials. + */ +export interface PickCredentialParams { + /** + * The IDs of the credentials that the user can pick from. + */ + cipherIds: string[]; + + /** + * Whether or not the user must be verified before completing the operation. + */ + userVerification: boolean; +} + +/** + * This service is used to provide a user interface with which the user can control FIDO2 operations. + * It acts as a way to remote control the user interface from the background script. + * + * The service is session based and is intended to be used by the FIDO2 authenticator to open a window, + * and then use this window to ask the user for input and/or display messages to the user. + */ +export abstract class Fido2UserInterfaceService { + /** + * Creates a new session. + * Note: This will not necessarily open a window until it is needed to request something from the user. + * + * @param fallbackSupported Whether or not the browser natively supports WebAuthn. + * @param abortController An abort controller that can be used to cancel/close the session. + */ + newSession: ( + fallbackSupported: boolean, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; +} + +export abstract class Fido2UserInterfaceSession { + /** + * Ask the user to pick a credential from a list of existing credentials. + * + * @param params The parameters to use when asking the user to pick a credential. + * @param abortController An abort controller that can be used to cancel/close the session. + * @returns The ID of the cipher that contains the credentials the user picked. + */ + pickCredential: ( + params: PickCredentialParams + ) => Promise<{ cipherId: string; userVerified: boolean }>; + + /** + * Ask the user to confirm the creation of a new credential. + * + * @param params The parameters to use when asking the user to confirm the creation of a new credential. + * @param abortController An abort controller that can be used to cancel/close the session. + * @returns The ID of the cipher where the new credential should be saved. + */ + confirmNewCredential: ( + params: NewCredentialParams + ) => Promise<{ cipherId: string; userVerified: boolean }>; + + /** + * Make sure that the vault is unlocked. + * This will open a window and ask the user to login or unlock the vault if necessary. + */ + ensureUnlockedVault: () => Promise; + + /** + * Inform the user that the operation was cancelled because their vault contains excluded credentials. + * + * @param existingCipherIds The IDs of the excluded credentials. + */ + informExcludedCredential: (existingCipherIds: string[]) => Promise; + + /** + * Inform the user that the operation was cancelled because their vault does not contain any useable credentials. + */ + informCredentialNotFound: (abortController?: AbortController) => Promise; + + /** + * Close the session, including any windows that may be open. + */ + close: () => void; +} diff --git a/libs/common/src/vault/api/fido2-credential.api.ts b/libs/common/src/vault/api/fido2-credential.api.ts new file mode 100644 index 00000000000..bfe32fc9b56 --- /dev/null +++ b/libs/common/src/vault/api/fido2-credential.api.ts @@ -0,0 +1,36 @@ +import { BaseResponse } from "../../models/response/base.response"; + +export class Fido2CredentialApi extends BaseResponse { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle: string; + counter: string; + rpName: string; + userDisplayName: string; + discoverable: string; + creationDate: string; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.credentialId = this.getResponseProperty("CredentialId"); + this.keyType = this.getResponseProperty("KeyType"); + this.keyAlgorithm = this.getResponseProperty("KeyAlgorithm"); + this.keyCurve = this.getResponseProperty("KeyCurve"); + this.keyValue = this.getResponseProperty("keyValue"); + this.rpId = this.getResponseProperty("RpId"); + this.userHandle = this.getResponseProperty("UserHandle"); + this.counter = this.getResponseProperty("Counter"); + this.rpName = this.getResponseProperty("RpName"); + this.userDisplayName = this.getResponseProperty("UserDisplayName"); + this.discoverable = this.getResponseProperty("Discoverable"); + this.creationDate = this.getResponseProperty("CreationDate"); + } +} diff --git a/libs/common/src/vault/interfaces/launchable.ts b/libs/common/src/vault/interfaces/launchable.ts new file mode 100644 index 00000000000..512fc15419b --- /dev/null +++ b/libs/common/src/vault/interfaces/launchable.ts @@ -0,0 +1,4 @@ +export interface Launchable { + launchUri: string; + canLaunch: boolean; +} diff --git a/libs/common/src/vault/models/data/fido2-credential.data.ts b/libs/common/src/vault/models/data/fido2-credential.data.ts new file mode 100644 index 00000000000..8f5160d91ba --- /dev/null +++ b/libs/common/src/vault/models/data/fido2-credential.data.ts @@ -0,0 +1,35 @@ +import { Fido2CredentialApi } from "../../api/fido2-credential.api"; + +export class Fido2CredentialData { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle: string; + counter: string; + rpName: string; + userDisplayName: string; + discoverable: string; + creationDate: string; + + constructor(data?: Fido2CredentialApi) { + if (data == null) { + return; + } + + this.credentialId = data.credentialId; + this.keyType = data.keyType; + this.keyAlgorithm = data.keyAlgorithm; + this.keyCurve = data.keyCurve; + this.keyValue = data.keyValue; + this.rpId = data.rpId; + this.userHandle = data.userHandle; + this.counter = data.counter; + this.rpName = data.rpName; + this.userDisplayName = data.userDisplayName; + this.discoverable = data.discoverable; + this.creationDate = data.creationDate; + } +} diff --git a/libs/common/src/vault/models/data/login.data.ts b/libs/common/src/vault/models/data/login.data.ts index 585b46ac05b..0d8c71e77b4 100644 --- a/libs/common/src/vault/models/data/login.data.ts +++ b/libs/common/src/vault/models/data/login.data.ts @@ -1,5 +1,6 @@ import { LoginApi } from "../../../models/api/login.api"; +import { Fido2CredentialData } from "./fido2-credential.data"; import { LoginUriData } from "./login-uri.data"; export class LoginData { @@ -9,6 +10,7 @@ export class LoginData { passwordRevisionDate: string; totp: string; autofillOnPageLoad: boolean; + fido2Credentials?: Fido2CredentialData[]; constructor(data?: LoginApi) { if (data == null) { @@ -24,5 +26,9 @@ export class LoginData { if (data.uris) { this.uris = data.uris.map((u) => new LoginUriData(u)); } + + if (data.fido2Credentials) { + this.fido2Credentials = data.fido2Credentials?.map((key) => new Fido2CredentialData(key)); + } } } diff --git a/libs/common/src/vault/models/domain/fido2-credential.spec.ts b/libs/common/src/vault/models/domain/fido2-credential.spec.ts new file mode 100644 index 00000000000..0b2b76a19cd --- /dev/null +++ b/libs/common/src/vault/models/domain/fido2-credential.spec.ts @@ -0,0 +1,167 @@ +import { mockEnc } from "../../../../spec"; +import { EncryptionType } from "../../../enums"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { Fido2CredentialData } from "../data/fido2-credential.data"; + +import { Fido2Credential } from "./fido2-credential"; + +describe("Fido2Credential", () => { + let mockDate: Date; + + beforeEach(() => { + mockDate = new Date("2023-01-01T12:00:00.000Z"); + }); + + describe("constructor", () => { + it("returns all fields null when given empty data parameter", () => { + const data = new Fido2CredentialData(); + const credential = new Fido2Credential(data); + + expect(credential).toEqual({ + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + rpName: null, + userDisplayName: null, + counter: null, + discoverable: null, + creationDate: null, + }); + }); + + it("returns all fields as EncStrings except creationDate when given full Fido2CredentialData", () => { + const data: Fido2CredentialData = { + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + counter: "counter", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "discoverable", + creationDate: mockDate.toISOString(), + }; + const credential = new Fido2Credential(data); + + expect(credential).toEqual({ + credentialId: { encryptedString: "credentialId", encryptionType: 0 }, + keyType: { encryptedString: "public-key", encryptionType: 0 }, + keyAlgorithm: { encryptedString: "ECDSA", encryptionType: 0 }, + keyCurve: { encryptedString: "P-256", encryptionType: 0 }, + keyValue: { encryptedString: "keyValue", encryptionType: 0 }, + rpId: { encryptedString: "rpId", encryptionType: 0 }, + userHandle: { encryptedString: "userHandle", encryptionType: 0 }, + counter: { encryptedString: "counter", encryptionType: 0 }, + rpName: { encryptedString: "rpName", encryptionType: 0 }, + userDisplayName: { encryptedString: "userDisplayName", encryptionType: 0 }, + discoverable: { encryptedString: "discoverable", encryptionType: 0 }, + creationDate: mockDate, + }); + }); + + it("should not populate fields when data parameter is not given", () => { + const credential = new Fido2Credential(); + + expect(credential).toEqual({ + credentialId: null, + }); + }); + }); + + describe("decrypt", () => { + it("decrypts and populates all fields when populated with EncStrings", async () => { + const credential = new Fido2Credential(); + credential.credentialId = mockEnc("credentialId"); + credential.keyType = mockEnc("keyType"); + credential.keyAlgorithm = mockEnc("keyAlgorithm"); + credential.keyCurve = mockEnc("keyCurve"); + credential.keyValue = mockEnc("keyValue"); + credential.rpId = mockEnc("rpId"); + credential.userHandle = mockEnc("userHandle"); + credential.counter = mockEnc("2"); + credential.rpName = mockEnc("rpName"); + credential.userDisplayName = mockEnc("userDisplayName"); + credential.discoverable = mockEnc("true"); + credential.creationDate = mockDate; + + const credentialView = await credential.decrypt(null); + + expect(credentialView).toEqual({ + credentialId: "credentialId", + keyType: "keyType", + keyAlgorithm: "keyAlgorithm", + keyCurve: "keyCurve", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + rpName: "rpName", + userDisplayName: "userDisplayName", + counter: 2, + discoverable: true, + creationDate: mockDate, + }); + }); + }); + + describe("toFido2CredentialData", () => { + it("encodes to data object when converted from Fido2CredentialData and back", () => { + const data: Fido2CredentialData = { + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + counter: "2", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "true", + creationDate: mockDate.toISOString(), + }; + + const credential = new Fido2Credential(data); + const result = credential.toFido2CredentialData(); + + expect(result).toEqual(data); + }); + }); + + describe("fromJSON", () => { + it("recreates equivalent object when converted to JSON and back", () => { + const credential = new Fido2Credential(); + credential.credentialId = createEncryptedEncString("credentialId"); + credential.keyType = createEncryptedEncString("keyType"); + credential.keyAlgorithm = createEncryptedEncString("keyAlgorithm"); + credential.keyCurve = createEncryptedEncString("keyCurve"); + credential.keyValue = createEncryptedEncString("keyValue"); + credential.rpId = createEncryptedEncString("rpId"); + credential.userHandle = createEncryptedEncString("userHandle"); + credential.counter = createEncryptedEncString("2"); + credential.rpName = createEncryptedEncString("rpName"); + credential.userDisplayName = createEncryptedEncString("userDisplayName"); + credential.discoverable = createEncryptedEncString("discoverable"); + credential.creationDate = mockDate; + + const json = JSON.stringify(credential); + const result = Fido2Credential.fromJSON(JSON.parse(json)); + + expect(result).toEqual(credential); + }); + + it("returns null if input is null", () => { + expect(Fido2Credential.fromJSON(null)).toBeNull(); + }); + }); +}); + +function createEncryptedEncString(s: string): EncString { + return new EncString(`${EncryptionType.AesCbc256_HmacSha256_B64}.${s}`); +} diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts new file mode 100644 index 00000000000..84f075458a8 --- /dev/null +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -0,0 +1,146 @@ +import { Jsonify } from "type-fest"; + +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { Fido2CredentialData } from "../data/fido2-credential.data"; +import { Fido2CredentialView } from "../view/fido2-credential.view"; + +export class Fido2Credential extends Domain { + credentialId: EncString | null = null; + keyType: EncString; + keyAlgorithm: EncString; + keyCurve: EncString; + keyValue: EncString; + rpId: EncString; + userHandle: EncString; + counter: EncString; + rpName: EncString; + userDisplayName: EncString; + discoverable: EncString; + creationDate: Date; + + constructor(obj?: Fido2CredentialData) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel( + this, + obj, + { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + counter: null, + rpName: null, + userDisplayName: null, + discoverable: null, + }, + [] + ); + this.creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + } + + async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + const view = await this.decryptObj( + new Fido2CredentialView(), + { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + rpName: null, + userDisplayName: null, + discoverable: null, + }, + orgId, + encKey + ); + + const { counter } = await this.decryptObj( + { counter: "" }, + { + counter: null, + }, + orgId, + encKey + ); + // Counter will end up as NaN if this fails + view.counter = parseInt(counter); + + const { discoverable } = await this.decryptObj( + { discoverable: "" }, + { + discoverable: null, + }, + orgId, + encKey + ); + view.discoverable = discoverable === "true"; + view.creationDate = this.creationDate; + + return view; + } + + toFido2CredentialData(): Fido2CredentialData { + const i = new Fido2CredentialData(); + i.creationDate = this.creationDate.toISOString(); + this.buildDataModel(this, i, { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + counter: null, + rpName: null, + userDisplayName: null, + discoverable: null, + }); + return i; + } + + static fromJSON(obj: Jsonify): Fido2Credential { + if (obj == null) { + return null; + } + + const credentialId = EncString.fromJSON(obj.credentialId); + const keyType = EncString.fromJSON(obj.keyType); + const keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm); + const keyCurve = EncString.fromJSON(obj.keyCurve); + const keyValue = EncString.fromJSON(obj.keyValue); + const rpId = EncString.fromJSON(obj.rpId); + const userHandle = EncString.fromJSON(obj.userHandle); + const counter = EncString.fromJSON(obj.counter); + const rpName = EncString.fromJSON(obj.rpName); + const userDisplayName = EncString.fromJSON(obj.userDisplayName); + const discoverable = EncString.fromJSON(obj.discoverable); + const creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + + return Object.assign(new Fido2Credential(), obj, { + credentialId, + keyType, + keyAlgorithm, + keyCurve, + keyValue, + rpId, + userHandle, + counter, + rpName, + userDisplayName, + discoverable, + creationDate, + }); + } +} diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 0bd395b3406..d0b24d8c173 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -3,10 +3,15 @@ import { mock } from "jest-mock-extended"; import { mockEnc, mockFromJson } from "../../../../spec"; import { UriMatchType } from "../../../enums"; import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; +import { Fido2CredentialApi } from "../../api/fido2-credential.api"; import { LoginData } from "../../models/data/login.data"; import { Login } from "../../models/domain/login"; import { LoginUri } from "../../models/domain/login-uri"; import { LoginUriView } from "../../models/view/login-uri.view"; +import { Fido2CredentialData } from "../data/fido2-credential.data"; +import { Fido2CredentialView } from "../view/fido2-credential.view"; + +import { Fido2Credential } from "./fido2-credential"; describe("Login DTO", () => { it("Convert from empty LoginData", () => { @@ -23,6 +28,7 @@ describe("Login DTO", () => { }); it("Convert from full LoginData", () => { + const fido2CredentialData = initializeFido2Credential(new Fido2CredentialData()); const data: LoginData = { uris: [{ uri: "uri", match: UriMatchType.Domain }], username: "username", @@ -30,6 +36,7 @@ describe("Login DTO", () => { passwordRevisionDate: "2022-01-31T12:00:00.000Z", totp: "123", autofillOnPageLoad: false, + fido2Credentials: [fido2CredentialData], }; const login = new Login(data); @@ -40,6 +47,7 @@ describe("Login DTO", () => { password: { encryptedString: "password", encryptionType: 0 }, totp: { encryptedString: "123", encryptionType: 0 }, uris: [{ match: 0, uri: { encryptedString: "uri", encryptionType: 0 } }], + fido2Credentials: [encryptFido2Credential(fido2CredentialData)], }); }); @@ -56,12 +64,16 @@ describe("Login DTO", () => { loginUri.decrypt.mockResolvedValue(loginUriView); const login = new Login(); + const decryptedFido2Credential = Symbol(); login.uris = [loginUri]; login.username = mockEnc("encrypted username"); login.password = mockEnc("encrypted password"); login.passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z"); login.totp = mockEnc("encrypted totp"); login.autofillOnPageLoad = true; + login.fido2Credentials = [ + { decrypt: jest.fn().mockReturnValue(decryptedFido2Credential) } as any, + ]; const loginView = await login.decrypt(null); expect(loginView).toEqual({ @@ -80,6 +92,7 @@ describe("Login DTO", () => { }, ], autofillOnPageLoad: true, + fido2Credentials: [decryptedFido2Credential], }); }); @@ -91,6 +104,7 @@ describe("Login DTO", () => { passwordRevisionDate: "2022-01-31T12:00:00.000Z", totp: "123", autofillOnPageLoad: false, + fido2Credentials: [initializeFido2Credential(new Fido2CredentialData())], }; const login = new Login(data); @@ -104,6 +118,7 @@ describe("Login DTO", () => { jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); jest.spyOn(LoginUri, "fromJSON").mockImplementation(mockFromJson); const passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z"); + const fido2CreationDate = new Date("2023-01-01T12:00:00.000Z"); const actual = Login.fromJSON({ uris: ["loginUri1", "loginUri2"] as any, @@ -111,6 +126,22 @@ describe("Login DTO", () => { password: "myPassword" as EncryptedString, passwordRevisionDate: passwordRevisionDate.toISOString(), totp: "myTotp" as EncryptedString, + fido2Credentials: [ + { + credentialId: "keyId" as EncryptedString, + keyType: "keyType" as EncryptedString, + keyAlgorithm: "keyAlgorithm" as EncryptedString, + keyCurve: "keyCurve" as EncryptedString, + keyValue: "keyValue" as EncryptedString, + rpId: "rpId" as EncryptedString, + userHandle: "userHandle" as EncryptedString, + counter: "counter" as EncryptedString, + rpName: "rpName" as EncryptedString, + userDisplayName: "userDisplayName" as EncryptedString, + discoverable: "discoverable" as EncryptedString, + creationDate: fido2CreationDate.toISOString(), + }, + ], }); expect(actual).toEqual({ @@ -119,6 +150,22 @@ describe("Login DTO", () => { password: "myPassword_fromJSON", passwordRevisionDate: passwordRevisionDate, totp: "myTotp_fromJSON", + fido2Credentials: [ + { + credentialId: "keyId_fromJSON", + keyType: "keyType_fromJSON", + keyAlgorithm: "keyAlgorithm_fromJSON", + keyCurve: "keyCurve_fromJSON", + keyValue: "keyValue_fromJSON", + rpId: "rpId_fromJSON", + userHandle: "userHandle_fromJSON", + counter: "counter_fromJSON", + rpName: "rpName_fromJSON", + userDisplayName: "userDisplayName_fromJSON", + discoverable: "discoverable_fromJSON", + creationDate: fido2CreationDate, + }, + ], }); expect(actual).toBeInstanceOf(Login); }); @@ -128,3 +175,42 @@ describe("Login DTO", () => { }); }); }); + +type Fido2CredentialLike = Fido2CredentialData | Fido2CredentialView | Fido2CredentialApi; +function initializeFido2Credential(key: T): T { + key.credentialId = "credentialId"; + key.keyType = "public-key"; + key.keyAlgorithm = "ECDSA"; + key.keyCurve = "P-256"; + key.keyValue = "keyValue"; + key.rpId = "rpId"; + key.userHandle = "userHandle"; + key.counter = "counter"; + key.rpName = "rpName"; + key.userDisplayName = "userDisplayName"; + key.discoverable = "discoverable"; + key.creationDate = "2023-01-01T12:00:00.000Z"; + return key; +} + +function encryptFido2Credential(key: Fido2CredentialLike): Fido2Credential { + const encrypted = new Fido2Credential(); + encrypted.credentialId = { encryptedString: key.credentialId, encryptionType: 0 } as EncString; + encrypted.keyType = { encryptedString: key.keyType, encryptionType: 0 } as EncString; + encrypted.keyAlgorithm = { encryptedString: key.keyAlgorithm, encryptionType: 0 } as EncString; + encrypted.keyCurve = { encryptedString: key.keyCurve, encryptionType: 0 } as EncString; + encrypted.keyValue = { encryptedString: key.keyValue, encryptionType: 0 } as EncString; + encrypted.rpId = { encryptedString: key.rpId, encryptionType: 0 } as EncString; + encrypted.userHandle = { encryptedString: key.userHandle, encryptionType: 0 } as EncString; + encrypted.counter = { encryptedString: key.counter, encryptionType: 0 } as EncString; + encrypted.rpName = { encryptedString: key.rpName, encryptionType: 0 } as EncString; + encrypted.userDisplayName = { + encryptedString: key.userDisplayName, + encryptionType: 0, + } as EncString; + encrypted.discoverable = { encryptedString: key.discoverable, encryptionType: 0 } as EncString; + + // not encrypted + encrypted.creationDate = new Date(key.creationDate); + return encrypted; +} diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index bc046e784db..64517b07558 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -6,6 +6,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { LoginData } from "../data/login.data"; import { LoginView } from "../view/login.view"; +import { Fido2Credential } from "./fido2-credential"; import { LoginUri } from "./login-uri"; export class Login extends Domain { @@ -15,6 +16,7 @@ export class Login extends Domain { passwordRevisionDate?: Date; totp: EncString; autofillOnPageLoad: boolean; + fido2Credentials: Fido2Credential[]; constructor(obj?: LoginData) { super(); @@ -42,6 +44,10 @@ export class Login extends Domain { this.uris.push(new LoginUri(u)); }); } + + if (obj.fido2Credentials) { + this.fido2Credentials = obj.fido2Credentials.map((key) => new Fido2Credential(key)); + } } async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { @@ -64,6 +70,12 @@ export class Login extends Domain { } } + if (this.fido2Credentials != null) { + view.fido2Credentials = await Promise.all( + this.fido2Credentials.map((key) => key.decrypt(orgId, encKey)) + ); + } + return view; } @@ -85,6 +97,10 @@ export class Login extends Domain { }); } + if (this.fido2Credentials != null && this.fido2Credentials.length > 0) { + l.fido2Credentials = this.fido2Credentials.map((key) => key.toFido2CredentialData()); + } + return l; } @@ -99,13 +115,16 @@ export class Login extends Domain { const passwordRevisionDate = obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); const uris = obj.uris?.map((uri: any) => LoginUri.fromJSON(uri)); + const fido2Credentials = + obj.fido2Credentials?.map((key) => Fido2Credential.fromJSON(key)) ?? []; return Object.assign(new Login(), obj, { username, password, totp, - passwordRevisionDate: passwordRevisionDate, - uris: uris, + passwordRevisionDate, + uris, + fido2Credentials, }); } } diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 0f34200e79e..949d36ab051 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -4,6 +4,7 @@ import { IdentityApi } from "../../../models/api/identity.api"; import { LoginUriApi } from "../../../models/api/login-uri.api"; import { LoginApi } from "../../../models/api/login.api"; import { SecureNoteApi } from "../../../models/api/secure-note.api"; +import { Fido2CredentialApi } from "../../api/fido2-credential.api"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { Cipher } from "../domain/cipher"; @@ -63,6 +64,31 @@ export class CipherRequest { return uri; }); } + + if (cipher.login.fido2Credentials != null) { + this.login.fido2Credentials = cipher.login.fido2Credentials.map((key) => { + const keyApi = new Fido2CredentialApi(); + keyApi.credentialId = + key.credentialId != null ? key.credentialId.encryptedString : null; + keyApi.keyType = + key.keyType != null ? (key.keyType.encryptedString as "public-key") : null; + keyApi.keyAlgorithm = + key.keyAlgorithm != null ? (key.keyAlgorithm.encryptedString as "ECDSA") : null; + keyApi.keyCurve = + key.keyCurve != null ? (key.keyCurve.encryptedString as "P-256") : null; + keyApi.keyValue = key.keyValue != null ? key.keyValue.encryptedString : null; + keyApi.rpId = key.rpId != null ? key.rpId.encryptedString : null; + keyApi.rpName = key.rpName != null ? key.rpName.encryptedString : null; + keyApi.counter = key.counter != null ? key.counter.encryptedString : null; + keyApi.userHandle = key.userHandle != null ? key.userHandle.encryptedString : null; + keyApi.userDisplayName = + key.userDisplayName != null ? key.userDisplayName.encryptedString : null; + keyApi.discoverable = + key.discoverable != null ? key.discoverable.encryptedString : null; + keyApi.creationDate = key.creationDate != null ? key.creationDate.toISOString() : null; + return keyApi; + }); + } break; case CipherType.SecureNote: this.secureNote = new SecureNoteApi(); diff --git a/libs/common/src/vault/models/view/fido2-credential.view.ts b/libs/common/src/vault/models/view/fido2-credential.view.ts new file mode 100644 index 00000000000..b6894e84ff5 --- /dev/null +++ b/libs/common/src/vault/models/view/fido2-credential.view.ts @@ -0,0 +1,29 @@ +import { Jsonify } from "type-fest"; + +import { ItemView } from "./item.view"; + +export class Fido2CredentialView extends ItemView { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle: string; + counter: number; + rpName: string; + userDisplayName: string; + discoverable: boolean; + creationDate: Date = null; + + get subTitle(): string { + return this.userDisplayName; + } + + static fromJSON(obj: Partial>): Fido2CredentialView { + const creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + return Object.assign(new Fido2CredentialView(), obj, { + creationDate, + }); + } +} diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 954a14fe8e9..fa05189ba0c 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -5,6 +5,7 @@ import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator"; import { Utils } from "../../../platform/misc/utils"; import { Login } from "../domain/login"; +import { Fido2CredentialView } from "./fido2-credential.view"; import { ItemView } from "./item.view"; import { LoginUriView } from "./login-uri.view"; @@ -18,6 +19,7 @@ export class LoginView extends ItemView { totp: string = null; uris: LoginUriView[] = null; autofillOnPageLoad: boolean = null; + fido2Credentials: Fido2CredentialView[] = null; constructor(l?: Login) { super(); @@ -63,6 +65,10 @@ export class LoginView extends ItemView { return this.uris != null && this.uris.length > 0; } + get hasFido2Credentials(): boolean { + return this.fido2Credentials != null && this.fido2Credentials.length > 0; + } + matchesUri( targetUri: string, equivalentDomains: Set, @@ -79,10 +85,12 @@ export class LoginView extends ItemView { const passwordRevisionDate = obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); const uris = obj.uris?.map((uri: any) => LoginUriView.fromJSON(uri)); + const fido2Credentials = obj.fido2Credentials?.map((key) => Fido2CredentialView.fromJSON(key)); return Object.assign(new LoginView(), obj, { - passwordRevisionDate: passwordRevisionDate, - uris: uris, + passwordRevisionDate, + uris, + fido2Credentials, }); } } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index b9bbc3e291e..b5090a14883 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -30,6 +30,7 @@ import { CipherData } from "../models/data/cipher.data"; import { Attachment } from "../models/domain/attachment"; import { Card } from "../models/domain/card"; import { Cipher } from "../models/domain/cipher"; +import { Fido2Credential } from "../models/domain/fido2-credential"; import { Field } from "../models/domain/field"; import { Identity } from "../models/domain/identity"; import { Login } from "../models/domain/login"; @@ -1136,6 +1137,38 @@ export class CipherService implements CipherServiceAbstraction { cipher.login.uris.push(loginUri); } } + + if (model.login.fido2Credentials != null) { + cipher.login.fido2Credentials = await Promise.all( + model.login.fido2Credentials.map(async (viewKey) => { + const domainKey = new Fido2Credential(); + await this.encryptObjProperty( + viewKey, + domainKey, + { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + rpName: null, + userHandle: null, + userDisplayName: null, + origin: null, + }, + key + ); + domainKey.counter = await this.cryptoService.encrypt(String(viewKey.counter), key); + domainKey.discoverable = await this.cryptoService.encrypt( + String(viewKey.discoverable), + key + ); + domainKey.creationDate = viewKey.creationDate; + return domainKey; + }) + ); + } return; case CipherType.SecureNote: cipher.secureNote = new SecureNote(); diff --git a/libs/common/src/vault/services/fido2/cbor.ts b/libs/common/src/vault/services/fido2/cbor.ts new file mode 100644 index 00000000000..b74822fd4b6 --- /dev/null +++ b/libs/common/src/vault/services/fido2/cbor.ts @@ -0,0 +1,494 @@ +/** +The MIT License (MIT) + +Copyright (c) 2014-2016 Patrick Gansterer +Copyright (c) 2020-present Aaron Huggins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Exported from GitHub release version 0.4.0 +*/ + +/* eslint-disable */ +/** @hidden */ +const POW_2_24 = 5.960464477539063e-8; +/** @hidden */ +const POW_2_32 = 4294967296; +/** @hidden */ +const POW_2_53 = 9007199254740992; +/** @hidden */ +const DECODE_CHUNK_SIZE = 8192; + +/** @hidden */ +function objectIs(x: any, y: any) { + if (typeof Object.is === "function") return Object.is(x, y); + + // SameValue algorithm + // Steps 1-5, 7-10 + if (x === y) { + // Steps 6.b-6.e: +0 != -0 + return x !== 0 || 1 / x === 1 / y; + } + + // Step 6.a: NaN == NaN + return x !== x && y !== y; +} + +/** A function that extracts tagged values. */ +type TaggedValueFunction = (value: any, tag: number) => TaggedValue; +/** A function that extracts simple values. */ +type SimpleValueFunction = (value: any) => SimpleValue; + +/** Convenience class for structuring a tagged value. */ +export class TaggedValue { + constructor(value: any, tag: number) { + this.value = value; + this.tag = tag; + } + + value: any; + tag: number; +} + +/** Convenience class for structuring a simple value. */ +export class SimpleValue { + constructor(value: any) { + this.value = value; + } + + value: any; +} + +/** + * Converts a Concise Binary Object Representation (CBOR) buffer into an object. + * @param {ArrayBuffer|SharedArrayBuffer} data - A valid CBOR buffer. + * @param {Function} [tagger] - A function that extracts tagged values. This function is called for each member of the object. + * @param {Function} [simpleValue] - A function that extracts simple values. This function is called for each member of the object. + * @returns {any} The CBOR buffer converted to a JavaScript value. + */ +export function decode( + data: ArrayBuffer | SharedArrayBuffer, + tagger?: TaggedValueFunction, + simpleValue?: SimpleValueFunction +): T { + let dataView = new DataView(data); + let ta = new Uint8Array(data); + let offset = 0; + let tagValueFunction: TaggedValueFunction = function (value: number, tag: number): any { + return new TaggedValue(value, tag); + }; + let simpleValFunction: SimpleValueFunction = function (value: number): SimpleValue { + return undefined as unknown as SimpleValue; + }; + + if (typeof tagger === "function") tagValueFunction = tagger; + if (typeof simpleValue === "function") simpleValFunction = simpleValue; + + function commitRead(length: number, value: T): T { + offset += length; + return value; + } + function readArrayBuffer(length: number) { + return commitRead(length, new Uint8Array(data, offset, length)); + } + function readFloat16() { + let tempArrayBuffer = new ArrayBuffer(4); + let tempDataView = new DataView(tempArrayBuffer); + let value = readUint16(); + + let sign = value & 0x8000; + let exponent = value & 0x7c00; + let fraction = value & 0x03ff; + + if (exponent === 0x7c00) exponent = 0xff << 10; + else if (exponent !== 0) exponent += (127 - 15) << 10; + else if (fraction !== 0) return (sign ? -1 : 1) * fraction * POW_2_24; + + tempDataView.setUint32(0, (sign << 16) | (exponent << 13) | (fraction << 13)); + return tempDataView.getFloat32(0); + } + function readFloat32(): number { + return commitRead(4, dataView.getFloat32(offset)); + } + function readFloat64(): number { + return commitRead(8, dataView.getFloat64(offset)); + } + function readUint8(): number { + return commitRead(1, ta[offset]); + } + function readUint16(): number { + return commitRead(2, dataView.getUint16(offset)); + } + function readUint32(): number { + return commitRead(4, dataView.getUint32(offset)); + } + function readUint64(): number { + return readUint32() * POW_2_32 + readUint32(); + } + function readBreak(): boolean { + if (ta[offset] !== 0xff) return false; + offset += 1; + return true; + } + function readLength(additionalInformation: number): number { + if (additionalInformation < 24) return additionalInformation; + if (additionalInformation === 24) return readUint8(); + if (additionalInformation === 25) return readUint16(); + if (additionalInformation === 26) return readUint32(); + if (additionalInformation === 27) return readUint64(); + if (additionalInformation === 31) return -1; + throw new Error("Invalid length encoding"); + } + function readIndefiniteStringLength(majorType: number): number { + let initialByte = readUint8(); + if (initialByte === 0xff) return -1; + let length = readLength(initialByte & 0x1f); + if (length < 0 || initialByte >> 5 !== majorType) + throw new Error("Invalid indefinite length element"); + return length; + } + + function appendUtf16Data(utf16data: number[], length: number) { + for (let i = 0; i < length; ++i) { + let value = readUint8(); + if (value & 0x80) { + if (value < 0xe0) { + value = ((value & 0x1f) << 6) | (readUint8() & 0x3f); + length -= 1; + } else if (value < 0xf0) { + value = ((value & 0x0f) << 12) | ((readUint8() & 0x3f) << 6) | (readUint8() & 0x3f); + length -= 2; + } else { + value = + ((value & 0x0f) << 18) | + ((readUint8() & 0x3f) << 12) | + ((readUint8() & 0x3f) << 6) | + (readUint8() & 0x3f); + length -= 3; + } + } + + if (value < 0x10000) { + utf16data.push(value); + } else { + value -= 0x10000; + utf16data.push(0xd800 | (value >> 10)); + utf16data.push(0xdc00 | (value & 0x3ff)); + } + } + } + + function decodeItem(): any { + let initialByte = readUint8(); + let majorType = initialByte >> 5; + let additionalInformation = initialByte & 0x1f; + let i; + let length; + + if (majorType === 7) { + switch (additionalInformation) { + case 25: + return readFloat16(); + case 26: + return readFloat32(); + case 27: + return readFloat64(); + } + } + + length = readLength(additionalInformation); + if (length < 0 && (majorType < 2 || 6 < majorType)) throw new Error("Invalid length"); + + switch (majorType) { + case 0: + return length; + case 1: + return -1 - length; + case 2: + if (length < 0) { + let elements = []; + let fullArrayLength = 0; + while ((length = readIndefiniteStringLength(majorType)) >= 0) { + fullArrayLength += length; + elements.push(readArrayBuffer(length)); + } + let fullArray = new Uint8Array(fullArrayLength); + let fullArrayOffset = 0; + for (i = 0; i < elements.length; ++i) { + fullArray.set(elements[i], fullArrayOffset); + fullArrayOffset += elements[i].length; + } + return fullArray; + } + return readArrayBuffer(length); + case 3: + let utf16data: number[] = []; + if (length < 0) { + while ((length = readIndefiniteStringLength(majorType)) >= 0) + appendUtf16Data(utf16data, length); + } else { + appendUtf16Data(utf16data, length); + } + let string = ""; + for (i = 0; i < utf16data.length; i += DECODE_CHUNK_SIZE) { + string += String.fromCharCode.apply(null, utf16data.slice(i, i + DECODE_CHUNK_SIZE)); + } + return string; + case 4: + let retArray; + if (length < 0) { + retArray = []; + while (!readBreak()) retArray.push(decodeItem()); + } else { + retArray = new Array(length); + for (i = 0; i < length; ++i) retArray[i] = decodeItem(); + } + return retArray; + case 5: + let retObject: any = {}; + for (i = 0; i < length || (length < 0 && !readBreak()); ++i) { + let key = decodeItem(); + retObject[key] = decodeItem(); + } + return retObject; + case 6: + return tagValueFunction(decodeItem(), length); + case 7: + switch (length) { + case 20: + return false; + case 21: + return true; + case 22: + return null; + case 23: + return undefined; + default: + return simpleValFunction(length); + } + } + } + + let ret = decodeItem(); + if (offset !== data.byteLength) throw new Error("Remaining bytes"); + return ret; +} + +/** + * Converts a JavaScript value to a Concise Binary Object Representation (CBOR) buffer. + * @param {any} value - A JavaScript value, usually an object or array, to be converted. + * @returns {ArrayBuffer} The JavaScript value converted to CBOR format. + */ +export function encode(value: T): ArrayBuffer { + let data = new ArrayBuffer(256); + let dataView = new DataView(data); + let byteView = new Uint8Array(data); + let lastLength: number; + let offset = 0; + + function prepareWrite(length: number): DataView { + let newByteLength = data.byteLength; + let requiredLength = offset + length; + while (newByteLength < requiredLength) newByteLength <<= 1; + if (newByteLength !== data.byteLength) { + let oldDataView = dataView; + data = new ArrayBuffer(newByteLength); + dataView = new DataView(data); + byteView = new Uint8Array(data); + let uint32count = (offset + 3) >> 2; + for (let i = 0; i < uint32count; ++i) + dataView.setUint32(i << 2, oldDataView.getUint32(i << 2)); + } + + lastLength = length; + return dataView; + } + function commitWrite(...args: any[]) { + offset += lastLength; + } + function writeFloat64(val: number) { + commitWrite(prepareWrite(8).setFloat64(offset, val)); + } + function writeUint8(val: number) { + commitWrite(prepareWrite(1).setUint8(offset, val)); + } + function writeUint8Array(val: number[] | Uint8Array) { + prepareWrite(val.length); + byteView.set(val, offset); + commitWrite(); + } + function writeUint16(val: number) { + commitWrite(prepareWrite(2).setUint16(offset, val)); + } + function writeUint32(val: number) { + commitWrite(prepareWrite(4).setUint32(offset, val)); + } + function writeUint64(val: number) { + let low = val % POW_2_32; + let high = (val - low) / POW_2_32; + let view = prepareWrite(8); + view.setUint32(offset, high); + view.setUint32(offset + 4, low); + commitWrite(); + } + function writeVarUint(val: number, mod: number = 0) { + if (val <= 0xff) { + if (val < 24) { + writeUint8(val | mod); + } else { + writeUint8(0x18 | mod); + writeUint8(val); + } + } else if (val <= 0xffff) { + writeUint8(0x19 | mod); + writeUint16(val); + } else if (val <= 0xffffffff) { + writeUint8(0x1a | mod); + writeUint32(val); + } else { + writeUint8(0x1b | mod); + writeUint64(val); + } + } + function writeTypeAndLength(type: number, length: number) { + if (length < 24) { + writeUint8((type << 5) | length); + } else if (length < 0x100) { + writeUint8((type << 5) | 24); + writeUint8(length); + } else if (length < 0x10000) { + writeUint8((type << 5) | 25); + writeUint16(length); + } else if (length < 0x100000000) { + writeUint8((type << 5) | 26); + writeUint32(length); + } else { + writeUint8((type << 5) | 27); + writeUint64(length); + } + } + + function encodeItem(val: any) { + let i; + + if (val === false) return writeUint8(0xf4); + if (val === true) return writeUint8(0xf5); + if (val === null) return writeUint8(0xf6); + if (val === undefined) return writeUint8(0xf7); + if (objectIs(val, -0)) return writeUint8Array([0xf9, 0x80, 0x00]); + + switch (typeof val) { + case "number": + if (Math.floor(val) === val) { + if (0 <= val && val <= POW_2_53) return writeTypeAndLength(0, val); + if (-POW_2_53 <= val && val < 0) return writeTypeAndLength(1, -(val + 1)); + } + writeUint8(0xfb); + return writeFloat64(val); + + case "string": + let utf8data = []; + for (i = 0; i < val.length; ++i) { + let charCode = val.charCodeAt(i); + if (charCode < 0x80) { + utf8data.push(charCode); + } else if (charCode < 0x800) { + utf8data.push(0xc0 | (charCode >> 6)); + utf8data.push(0x80 | (charCode & 0x3f)); + } else if (charCode < 0xd800 || charCode >= 0xe000) { + utf8data.push(0xe0 | (charCode >> 12)); + utf8data.push(0x80 | ((charCode >> 6) & 0x3f)); + utf8data.push(0x80 | (charCode & 0x3f)); + } else { + charCode = (charCode & 0x3ff) << 10; + charCode |= val.charCodeAt(++i) & 0x3ff; + charCode += 0x10000; + + utf8data.push(0xf0 | (charCode >> 18)); + utf8data.push(0x80 | ((charCode >> 12) & 0x3f)); + utf8data.push(0x80 | ((charCode >> 6) & 0x3f)); + utf8data.push(0x80 | (charCode & 0x3f)); + } + } + + writeTypeAndLength(3, utf8data.length); + return writeUint8Array(utf8data); + + default: + let length; + let converted; + if (Array.isArray(val)) { + length = val.length; + writeTypeAndLength(4, length); + for (i = 0; i < length; i += 1) encodeItem(val[i]); + } else if (val instanceof Uint8Array) { + writeTypeAndLength(2, val.length); + writeUint8Array(val); + } else if (ArrayBuffer.isView(val)) { + converted = new Uint8Array(val.buffer); + writeTypeAndLength(2, converted.length); + writeUint8Array(converted); + } else if ( + val instanceof ArrayBuffer || + (typeof SharedArrayBuffer === "function" && val instanceof SharedArrayBuffer) + ) { + converted = new Uint8Array(val); + writeTypeAndLength(2, converted.length); + writeUint8Array(converted); + } else if (val instanceof TaggedValue) { + writeVarUint(val.tag, 0b11000000); + encodeItem(val.value); + } else { + let keys = Object.keys(val); + length = keys.length; + writeTypeAndLength(5, length); + for (i = 0; i < length; i += 1) { + let key = keys[i]; + encodeItem(key); + encodeItem(val[key]); + } + } + } + } + + encodeItem(value); + + if ("slice" in data) return data.slice(0, offset); + + let ret = new ArrayBuffer(offset); + let retView = new DataView(ret); + for (let i = 0; i < offset; ++i) retView.setUint8(i, dataView.getUint8(i)); + return ret; +} + +/** + * An intrinsic object that provides functions to convert JavaScript values + * to and from the Concise Binary Object Representation (CBOR) format. + */ +export const CBOR: { + decode: ( + data: ArrayBuffer | SharedArrayBuffer, + tagger?: TaggedValueFunction, + simpleValue?: SimpleValueFunction + ) => T; + encode: (value: T) => ArrayBuffer; +} = { + decode, + encode, +}; diff --git a/libs/common/src/vault/services/fido2/domain-utils.spec.ts b/libs/common/src/vault/services/fido2/domain-utils.spec.ts new file mode 100644 index 00000000000..7c9c27869a2 --- /dev/null +++ b/libs/common/src/vault/services/fido2/domain-utils.spec.ts @@ -0,0 +1,53 @@ +import { isValidRpId } from "./domain-utils"; + +// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. +describe("validateRpId", () => { + it("should not be valid when rpId is more specific than origin", () => { + const rpId = "sub.login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); + + it("should not be valid when effective domains of rpId and origin do not match", () => { + const rpId = "passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); + + it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => { + const rpId = "login.passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); + + it("should be valid when domains of rpId and origin are the same", () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); + + it("should be valid when origin is a subdomain of rpId", () => { + const rpId = "bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); + + it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => { + const rpId = "login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); + + it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => { + const rpId = "login.bitwarden.com"; + const origin = "https://sub.login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); +}); diff --git a/libs/common/src/vault/services/fido2/domain-utils.ts b/libs/common/src/vault/services/fido2/domain-utils.ts new file mode 100644 index 00000000000..20b6e41700d --- /dev/null +++ b/libs/common/src/vault/services/fido2/domain-utils.ts @@ -0,0 +1,11 @@ +import { parse } from "tldts"; + +export function isValidRpId(rpId: string, origin: string) { + const parsedOrigin = parse(origin, { allowPrivateDomains: true }); + const parsedRpId = parse(rpId, { allowPrivateDomains: true }); + + return ( + parsedOrigin.domain === parsedRpId.domain && + parsedOrigin.subdomain.endsWith(parsedRpId.subdomain) + ); +} diff --git a/libs/common/src/vault/services/fido2/ecdsa-utils.ts b/libs/common/src/vault/services/fido2/ecdsa-utils.ts new file mode 100644 index 00000000000..1a4bf64de66 --- /dev/null +++ b/libs/common/src/vault/services/fido2/ecdsa-utils.ts @@ -0,0 +1,124 @@ +/* + Copyright 2015 D2L Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +// Changes: +// - Cherry-pick the methods that we have a need for. +// - Add typings. +// - Original code is made for running in node, this version is adapted to work in the browser. + +// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/param-bytes-for-alg.js + +function getParamSize(keySize: number) { + const result = ((keySize / 8) | 0) + (keySize % 8 === 0 ? 0 : 1); + return result; +} + +const paramBytesForAlg = { + ES256: getParamSize(256), + ES384: getParamSize(384), + ES512: getParamSize(521), +}; + +type Alg = keyof typeof paramBytesForAlg; + +function getParamBytesForAlg(alg: Alg) { + const paramBytes = paramBytesForAlg[alg]; + if (paramBytes) { + return paramBytes; + } + + throw new Error('Unknown algorithm "' + alg + '"'); +} + +// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/ecdsa-sig-formatter.js + +const MAX_OCTET = 0x80, + CLASS_UNIVERSAL = 0, + PRIMITIVE_BIT = 0x20, + TAG_SEQ = 0x10, + TAG_INT = 0x02, + ENCODED_TAG_SEQ = TAG_SEQ | PRIMITIVE_BIT | (CLASS_UNIVERSAL << 6), + ENCODED_TAG_INT = TAG_INT | (CLASS_UNIVERSAL << 6); + +function countPadding(buf: Uint8Array, start: number, stop: number) { + let padding = 0; + while (start + padding < stop && buf[start + padding] === 0) { + ++padding; + } + + const needsSign = buf[start + padding] >= MAX_OCTET; + if (needsSign) { + --padding; + } + + return padding; +} + +export function joseToDer(signature: Uint8Array, alg: Alg) { + const paramBytes = getParamBytesForAlg(alg); + + const signatureBytes = signature.length; + if (signatureBytes !== paramBytes * 2) { + throw new TypeError( + '"' + + alg + + '" signatures must be "' + + paramBytes * 2 + + '" bytes, saw "' + + signatureBytes + + '"' + ); + } + + const rPadding = countPadding(signature, 0, paramBytes); + const sPadding = countPadding(signature, paramBytes, signature.length); + const rLength = paramBytes - rPadding; + const sLength = paramBytes - sPadding; + + const rsBytes = 1 + 1 + rLength + 1 + 1 + sLength; + + const shortLength = rsBytes < MAX_OCTET; + + const dst = new Uint8Array((shortLength ? 2 : 3) + rsBytes); + + let offset = 0; + dst[offset++] = ENCODED_TAG_SEQ; + if (shortLength) { + dst[offset++] = rsBytes; + } else { + dst[offset++] = MAX_OCTET | 1; + dst[offset++] = rsBytes & 0xff; + } + dst[offset++] = ENCODED_TAG_INT; + dst[offset++] = rLength; + if (rPadding < 0) { + dst[offset++] = 0; + dst.set(signature.subarray(0, paramBytes), offset); + offset += paramBytes; + } else { + dst.set(signature.subarray(rPadding, paramBytes), offset); + offset += paramBytes; + } + dst[offset++] = ENCODED_TAG_INT; + dst[offset++] = sLength; + if (sPadding < 0) { + dst[offset++] = 0; + dst.set(signature.subarray(paramBytes), offset); + } else { + dst.set(signature.subarray(paramBytes + sPadding), offset); + } + + return dst; +} diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts new file mode 100644 index 00000000000..c519fccffc2 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts @@ -0,0 +1,826 @@ +import { TextEncoder } from "util"; + +import { mock, MockProxy } from "jest-mock-extended"; + +import { Utils } from "../../../platform/misc/utils"; +import { CipherService } from "../../abstractions/cipher.service"; +import { + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorMakeCredentialsParams, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { + Fido2UserInterfaceService, + Fido2UserInterfaceSession, + NewCredentialParams, +} from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; +import { SyncService } from "../../abstractions/sync/sync.service.abstraction"; +import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; +import { CipherType } from "../../enums/cipher-type"; +import { Cipher } from "../../models/domain/cipher"; +import { CipherView } from "../../models/view/cipher.view"; +import { Fido2CredentialView } from "../../models/view/fido2-credential.view"; +import { LoginView } from "../../models/view/login.view"; + +import { CBOR } from "./cbor"; +import { AAGUID, Fido2AuthenticatorService } from "./fido2-authenticator.service"; +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat } from "./guid-utils"; + +const RpId = "bitwarden.com"; + +describe("FidoAuthenticatorService", () => { + let cipherService!: MockProxy; + let userInterface!: MockProxy; + let userInterfaceSession!: MockProxy; + let syncService!: MockProxy; + let authenticator!: Fido2AuthenticatorService; + let tab!: chrome.tabs.Tab; + + beforeEach(async () => { + cipherService = mock(); + userInterface = mock(); + userInterfaceSession = mock(); + userInterface.newSession.mockResolvedValue(userInterfaceSession); + syncService = mock(); + authenticator = new Fido2AuthenticatorService(cipherService, userInterface, syncService); + tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; + }); + + describe("makeCredential", () => { + let invalidParams!: InvalidParams; + + beforeEach(async () => { + invalidParams = await createInvalidParams(); + }); + + describe("invalid input parameters", () => { + // Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation. + it("should throw error when input does not contain any supported algorithms", async () => { + const result = async () => + await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotSupported); + }); + + it("should throw error when requireResidentKey has invalid value", async () => { + const result = async () => await authenticator.makeCredential(invalidParams.invalidRk, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + it("should throw error when requireUserVerification has invalid value", async () => { + const result = async () => await authenticator.makeCredential(invalidParams.invalidUv, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + /** + * Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation. + * Deviation: User verification is checked before checking for excluded credentials + **/ + /** TODO: This test should only be activated if we disable support for user verification */ + it.skip("should throw error if requireUserVerification is set to true", async () => { + const params = await createParams({ requireUserVerification: true }); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint); + }); + + it("should not request confirmation from user", async () => { + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: "75280e7e-a72e-4d6c-bf1e-d37238352f9b", + userVerified: false, + }); + const invalidParams = await createInvalidParams(); + + for (const p of Object.values(invalidParams)) { + try { + await authenticator.makeCredential(p, tab); + // eslint-disable-next-line no-empty + } catch {} + } + expect(userInterfaceSession.confirmNewCredential).not.toHaveBeenCalled(); + }); + }); + + describe.skip("when extensions parameter is present", () => undefined); + + describe("vault contains excluded credential", () => { + let excludedCipher: CipherView; + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + excludedCipher = createCipherView( + { type: CipherType.Login }, + { credentialId: Utils.newGuid() } + ); + params = await createParams({ + excludeCredentialDescriptorList: [ + { + id: guidToRawFormat(excludedCipher.login.fido2Credentials[0].credentialId), + type: "public-key", + }, + ], + }); + cipherService.get.mockImplementation(async (id) => + id === excludedCipher.id ? ({ decrypt: () => excludedCipher } as any) : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([excludedCipher]); + }); + + /** + * Spec: collect an authorization gesture confirming user consent for creating a new credential. + * Deviation: Consent is not asked and the user is simply informed of the situation. + **/ + it("should inform user", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + + try { + await authenticator.makeCredential(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled(); + }); + + /** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + + /** Devation: Organization ciphers are not checked against excluded credentials, even if the user has access to them. */ + it("should not inform user of duplication when the excluded credential belongs to an organization", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + excludedCipher.organizationId = "someOrganizationId"; + + try { + await authenticator.makeCredential(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled(); + }); + + it("should not inform user of duplication when input data does not pass checks", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + const invalidParams = await createInvalidParams(); + + for (const p of Object.values(invalidParams)) { + try { + await authenticator.makeCredential(p, tab); + // eslint-disable-next-line no-empty + } catch {} + } + expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled(); + }); + + it.todo( + "should not throw error if the excluded credential has been marked as deleted in the vault" + ); + }); + + describe("credential creation", () => { + let existingCipher: CipherView; + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + existingCipher = createCipherView({ type: CipherType.Login }); + params = await createParams({ requireResidentKey: false }); + cipherService.get.mockImplementation(async (id) => + id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([existingCipher]); + }); + + /** + * Spec: Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible. + * Deviation: Only `rpEntity.name` and `userEntity.name` is shown. + * */ + for (const userVerification of [true, false]) { + it(`should request confirmation from user when user verification is ${userVerification}`, async () => { + params.requireUserVerification = userVerification; + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: userVerification, + }); + + await authenticator.makeCredential(params, tab); + + expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith({ + credentialName: params.rpEntity.name, + userName: params.userEntity.displayName, + userVerification, + } as NewCredentialParams); + }); + } + + it("should save credential to vault if request confirmed by user", async () => { + const encryptedCipher = Symbol(); + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: false, + }); + cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); + + await authenticator.makeCredential(params, tab); + + const saved = cipherService.encrypt.mock.lastCall?.[0]; + expect(saved).toEqual( + expect.objectContaining({ + type: CipherType.Login, + name: existingCipher.name, + + login: expect.objectContaining({ + fido2Credentials: [ + expect.objectContaining({ + credentialId: expect.anything(), + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: params.rpEntity.id, + rpName: params.rpEntity.name, + userHandle: Fido2Utils.bufferToString(params.userEntity.id), + counter: 0, + userDisplayName: params.userEntity.displayName, + discoverable: false, + }), + ], + }), + }) + ); + expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher); + }); + + /** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error if user denies creation request", async () => { + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: undefined, + userVerified: false, + }); + const params = await createParams(); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + + it("should throw error if user verification fails and cipher requires reprompt", async () => { + params.requireUserVerification = false; + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: false, + }); + const encryptedCipher = { ...existingCipher, reprompt: CipherRepromptType.Password }; + cipherService.get.mockResolvedValue(encryptedCipher as unknown as Cipher); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + /** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */ + it("should throw unkown error if creation fails", async () => { + const encryptedCipher = Symbol(); + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: false, + }); + cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); + cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + }); + + describe(`attestation of new credential`, () => { + const cipherId = "75280e7e-a72e-4d6c-bf1e-d37238352f9b"; + const credentialId = "52217b91-73f1-4fea-b3f2-54a7959fd5aa"; + const credentialIdBytes = new Uint8Array([ + 0x52, 0x21, 0x7b, 0x91, 0x73, 0xf1, 0x4f, 0xea, 0xb3, 0xf2, 0x54, 0xa7, 0x95, 0x9f, 0xd5, + 0xaa, + ]); + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + const cipher = createCipherView({ id: cipherId, type: CipherType.Login }); + params = await createParams(); + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId, + userVerified: false, + }); + cipherService.get.mockImplementation(async (cipherId) => + cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([await cipher]); + cipherService.encrypt.mockImplementation(async (cipher) => { + cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability + return {} as any; + }); + cipherService.createWithServer.mockImplementation(async (cipher) => { + cipher.id = cipherId; + return cipher; + }); + cipherService.updateWithServer.mockImplementation(async (cipher) => { + cipher.id = cipherId; + return cipher; + }); + }); + + it("should return attestation object", async () => { + const result = await authenticator.makeCredential(params, tab); + + const attestationObject = CBOR.decode( + Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer + ); + + const encAuthData: Uint8Array = attestationObject.authData; + const rpIdHash = encAuthData.slice(0, 32); + const flags = encAuthData.slice(32, 33); + const counter = encAuthData.slice(33, 37); + const aaguid = encAuthData.slice(37, 53); + const credentialIdLength = encAuthData.slice(53, 55); + const credentialId = encAuthData.slice(55, 71); + // Unsure how to test public key + // const publicKey = encAuthData.slice(87); + + expect(encAuthData.length).toBe(71 + 77); + expect(attestationObject.fmt).toBe("none"); + expect(attestationObject.attStmt).toEqual({}); + expect(rpIdHash).toEqual( + new Uint8Array([ + 0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8, + 0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1, + 0xd0, 0x5c, 0x3d, 0xc3, + ]) + ); + expect(flags).toEqual(new Uint8Array([0b01000001])); // UP = true, AD = true + expect(counter).toEqual(new Uint8Array([0, 0, 0, 0])); // 0 because of new counter + expect(aaguid).toEqual(AAGUID); + expect(credentialIdLength).toEqual(new Uint8Array([0, 16])); // 16 bytes because we're using GUIDs + expect(credentialId).toEqual(credentialIdBytes); + }); + }); + + async function createParams( + params: Partial = {} + ): Promise { + return { + hash: params.hash ?? (await createClientDataHash()), + rpEntity: params.rpEntity ?? { + name: "Bitwarden", + id: RpId, + }, + userEntity: params.userEntity ?? { + id: randomBytes(64), + name: "jane.doe@bitwarden.com", + displayName: "Jane Doe", + icon: " data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAOhJREFUeNpiFI+9E8DAwDAfiAUYSAMfgDiQBVmzlSYnUTqPXf/OANWzngVZ87pKKaIMCGp/BjeEhRjFMKAjx8bQFC2CIs9CpHNxAiYGCsEQM4Cfiwm3AY9f/yZogIcRN4ZahAFv/jAcu4E7xMNtecEYpAakFqsX8me9Yvj07R+G5jR3foaJqWJgOZAaZMAIzAv/kQV05NgZ5hdIMMiKQJIIyEYrDU6wrYkTXjBcefQTvwGwwCoJFGJIBdoMArN3fmToWf+O4SMW14EMeI8rJ8Jcgexn9BwJCoNEaNbEACCN+DSDsjNAgAEAri9Zii/uDMsAAAAASUVORK5CYII=", + }, + credTypesAndPubKeyAlgs: params.credTypesAndPubKeyAlgs ?? [ + { + alg: -7, // ES256 + type: "public-key", + }, + ], + excludeCredentialDescriptorList: params.excludeCredentialDescriptorList ?? [ + { + id: randomBytes(16), + transports: ["internal"], + type: "public-key", + }, + ], + requireResidentKey: params.requireResidentKey ?? false, + requireUserVerification: params.requireUserVerification ?? false, + fallbackSupported: params.fallbackSupported ?? false, + extensions: params.extensions ?? { + appid: undefined, + appidExclude: undefined, + credProps: undefined, + uvm: false as boolean, + }, + }; + } + + type InvalidParams = Awaited>; + async function createInvalidParams() { + return { + unsupportedAlgorithm: await createParams({ + credTypesAndPubKeyAlgs: [{ alg: 9001, type: "public-key" }], + }), + invalidRk: await createParams({ requireResidentKey: "invalid-value" as any }), + invalidUv: await createParams({ + requireUserVerification: "invalid-value" as any, + }), + }; + } + }); + + describe("getAssertion", () => { + let invalidParams!: InvalidParams; + + beforeEach(async () => { + invalidParams = await createInvalidParams(); + }); + + describe("invalid input parameters", () => { + it("should throw error when requireUserVerification has invalid value", async () => { + const result = async () => await authenticator.getAssertion(invalidParams.invalidUv, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + /** + * Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation. + * Deviation: User verification is checked before checking for excluded credentials + **/ + /** NOTE: This test should only be activated if we disable support for user verification */ + it.skip("should throw error if requireUserVerification is set to true", async () => { + const params = await createParams({ requireUserVerification: true }); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint); + }); + }); + + describe("vault is missing non-discoverable credential", () => { + let credentialId: string; + let params: Fido2AuthenticatorGetAssertionParams; + + beforeEach(async () => { + credentialId = Utils.newGuid(); + params = await createParams({ + allowCredentialDescriptorList: [ + { id: guidToRawFormat(credentialId), type: "public-key" }, + ], + rpId: RpId, + }); + }); + + /** + * Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. + * Deviation: We do not throw error but instead inform the user and allow the user to fallback to browser implementation. + **/ + it("should inform user if no credential exists", async () => { + cipherService.getAllDecrypted.mockResolvedValue([]); + userInterfaceSession.informCredentialNotFound.mockResolvedValue(); + + try { + await authenticator.getAssertion(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informCredentialNotFound).toHaveBeenCalled(); + }); + + it("should inform user if credential exists but rpId does not match", async () => { + const cipher = await createCipherView({ type: CipherType.Login }); + cipher.login.fido2Credentials[0].credentialId = credentialId; + cipher.login.fido2Credentials[0].rpId = "mismatch-rpid"; + cipherService.getAllDecrypted.mockResolvedValue([cipher]); + userInterfaceSession.informCredentialNotFound.mockResolvedValue(); + + try { + await authenticator.getAssertion(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informCredentialNotFound).toHaveBeenCalled(); + }); + }); + + describe("vault is missing discoverable credential", () => { + let params: Fido2AuthenticatorGetAssertionParams; + + beforeEach(async () => { + params = await createParams({ + allowCredentialDescriptorList: [], + rpId: RpId, + }); + cipherService.getAllDecrypted.mockResolvedValue([]); + }); + + /** Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error", async () => { + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + }); + + describe("vault contains credential", () => { + let credentialIds: string[]; + let ciphers: CipherView[]; + let params: Fido2AuthenticatorGetAssertionParams; + + beforeEach(async () => { + credentialIds = [Utils.newGuid(), Utils.newGuid()]; + ciphers = [ + await createCipherView( + { type: CipherType.Login }, + { credentialId: credentialIds[0], rpId: RpId, discoverable: false } + ), + await createCipherView( + { type: CipherType.Login }, + { credentialId: credentialIds[1], rpId: RpId, discoverable: true } + ), + ]; + params = await createParams({ + allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ + id: guidToRawFormat(credentialId), + type: "public-key", + })), + rpId: RpId, + }); + cipherService.getAllDecrypted.mockResolvedValue(ciphers); + }); + + it("should ask for all credentials in list when `params` contains allowedCredentials list", async () => { + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: false, + }); + + await authenticator.getAssertion(params, tab); + + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ + cipherIds: ciphers.map((c) => c.id), + userVerification: false, + }); + }); + + it("should only ask for discoverable credentials matched by rpId when params does not contains allowedCredentials list", async () => { + params.allowCredentialDescriptorList = undefined; + const discoverableCiphers = ciphers.filter((c) => c.login.fido2Credentials[0].discoverable); + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: discoverableCiphers[0].id, + userVerified: false, + }); + + await authenticator.getAssertion(params, tab); + + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ + cipherIds: [discoverableCiphers[0].id], + userVerification: false, + }); + }); + + for (const userVerification of [true, false]) { + /** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */ + it(`should request confirmation from user when user verification is ${userVerification}`, async () => { + params.requireUserVerification = userVerification; + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: userVerification, + }); + + await authenticator.getAssertion(params, tab); + + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ + cipherIds: ciphers.map((c) => c.id), + userVerification, + }); + }); + } + + /** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error", async () => { + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: undefined, + userVerified: false, + }); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + + it("should throw error if user verification fails and cipher requires reprompt", async () => { + ciphers[0].reprompt = CipherRepromptType.Password; + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: false, + }); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + }); + + describe("assertion of credential", () => { + let keyPair: CryptoKeyPair; + let credentialIds: string[]; + let selectedCredentialId: string; + let ciphers: CipherView[]; + let fido2Credentials: Fido2CredentialView[]; + let params: Fido2AuthenticatorGetAssertionParams; + + const init = async () => { + keyPair = await createKeyPair(); + credentialIds = [Utils.newGuid(), Utils.newGuid()]; + const keyValue = Fido2Utils.bufferToString( + await crypto.subtle.exportKey("pkcs8", keyPair.privateKey) + ); + ciphers = credentialIds.map((id) => + createCipherView( + { type: CipherType.Login }, + { credentialId: id, rpId: RpId, counter: 9000, keyValue } + ) + ); + fido2Credentials = ciphers.map((c) => c.login.fido2Credentials[0]); + selectedCredentialId = credentialIds[0]; + params = await createParams({ + allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ + id: guidToRawFormat(credentialId), + type: "public-key", + })), + rpId: RpId, + }); + cipherService.getAllDecrypted.mockResolvedValue(ciphers); + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: false, + }); + }; + beforeEach(init); + + /** Spec: Increment the credential associated signature counter */ + it("should increment counter", async () => { + const encrypted = Symbol(); + cipherService.encrypt.mockResolvedValue(encrypted as any); + + await authenticator.getAssertion(params, tab); + + expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); + + expect(cipherService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + id: ciphers[0].id, + login: expect.objectContaining({ + fido2Credentials: [ + expect.objectContaining({ + counter: 9001, + }), + ], + }), + }) + ); + }); + + it("should return an assertion result", async () => { + const result = await authenticator.getAssertion(params, tab); + + const encAuthData = result.authenticatorData; + const rpIdHash = encAuthData.slice(0, 32); + const flags = encAuthData.slice(32, 33); + const counter = encAuthData.slice(33, 37); + + expect(result.selectedCredential.id).toEqual(guidToRawFormat(selectedCredentialId)); + expect(result.selectedCredential.userHandle).toEqual( + Fido2Utils.stringToBuffer(fido2Credentials[0].userHandle) + ); + expect(rpIdHash).toEqual( + new Uint8Array([ + 0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8, + 0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1, + 0xd0, 0x5c, 0x3d, 0xc3, + ]) + ); + expect(flags).toEqual(new Uint8Array([0b00000001])); // UP = true + expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // 9001 in hex + + // Verify signature + // TODO: Cannot verify signature because it has been converted into DER format + // const sigBase = new Uint8Array([ + // ...result.authenticatorData, + // ...Fido2Utils.bufferSourceToUint8Array(params.hash), + // ]); + // const isValidSignature = await crypto.subtle.verify( + // { name: "ECDSA", hash: { name: "SHA-256" } }, + // keyPair.publicKey, + // result.signature, + // sigBase + // ); + // expect(isValidSignature).toBe(true); + }); + + it("should always generate unique signatures even if the input is the same", async () => { + const signatures = new Set(); + + for (let i = 0; i < 10; ++i) { + await init(); // Reset inputs + const result = await authenticator.getAssertion(params, tab); + + const counter = result.authenticatorData.slice(33, 37); + expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change + + const signature = Fido2Utils.bufferToString(result.signature); + if (signatures.has(signature)) { + throw new Error("Found duplicate signature"); + } + signatures.add(signature); + } + }); + + /** Spec: If any error occurred while generating the assertion signature, return an error code equivalent to "UnknownError" and terminate the operation. */ + it("should throw unkown error if creation fails", async () => { + cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + }); + + async function createParams( + params: Partial = {} + ): Promise { + return { + rpId: params.rpId ?? RpId, + hash: params.hash ?? (await createClientDataHash()), + allowCredentialDescriptorList: params.allowCredentialDescriptorList ?? [], + requireUserVerification: params.requireUserVerification ?? false, + extensions: params.extensions ?? {}, + fallbackSupported: params.fallbackSupported ?? false, + }; + } + + type InvalidParams = Awaited>; + async function createInvalidParams() { + const emptyRpId = await createParams(); + emptyRpId.rpId = undefined as any; + return { + emptyRpId, + invalidUv: await createParams({ + requireUserVerification: "invalid-value" as any, + }), + }; + } + }); +}); + +function createCipherView( + data: Partial> = {}, + fido2Credential: Partial = {} +): CipherView { + const cipher = new CipherView(); + cipher.id = data.id ?? Utils.newGuid(); + cipher.type = CipherType.Login; + cipher.localData = {}; + + const fido2CredentialView = new Fido2CredentialView(); + fido2CredentialView.credentialId = fido2Credential.credentialId ?? Utils.newGuid(); + fido2CredentialView.rpId = fido2Credential.rpId ?? RpId; + fido2CredentialView.counter = fido2Credential.counter ?? 0; + fido2CredentialView.userHandle = + fido2Credential.userHandle ?? Fido2Utils.bufferToString(randomBytes(16)); + fido2CredentialView.keyAlgorithm = fido2Credential.keyAlgorithm ?? "ECDSA"; + fido2CredentialView.keyCurve = fido2Credential.keyCurve ?? "P-256"; + fido2CredentialView.discoverable = fido2Credential.discoverable ?? true; + fido2CredentialView.keyValue = + fido2CredentialView.keyValue ?? + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTC-7XDZipXbaVBlnkjlBgO16ZmqBZWejK2iYo6lV0dehRANCAASOcM2WduNq1DriRYN7ZekvZz-bRhA-qNT4v0fbp5suUFJyWmgOQ0bybZcLXHaerK5Ep1JiSrQcewtQNgLtry7f"; + + cipher.login = new LoginView(); + cipher.login.fido2Credentials = [fido2CredentialView]; + + return cipher; +} + +async function createClientDataHash() { + const encoder = new TextEncoder(); + const clientData = encoder.encode( + JSON.stringify({ + type: "webauthn.create", + challenge: Fido2Utils.bufferToString(randomBytes(16)), + origin: RpId, + crossOrigin: false, + }) + ); + return await crypto.subtle.digest({ name: "SHA-256" }, clientData); +} + +/** This is a fake function that always returns the same byte sequence */ +function randomBytes(length: number) { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} + +async function createKeyPair() { + return await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign", "verify"] + ); +} diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts new file mode 100644 index 00000000000..e009d8b18ca --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts @@ -0,0 +1,545 @@ +import { LogService } from "../../../platform/abstractions/log.service"; +import { Utils } from "../../../platform/misc/utils"; +import { CipherService } from "../../abstractions/cipher.service"; +import { + Fido2AlgorithmIdentifier, + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorGetAssertionResult, + Fido2AuthenticatorMakeCredentialResult, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction, + PublicKeyCredentialDescriptor, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; +import { SyncService } from "../../abstractions/sync/sync.service.abstraction"; +import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; +import { CipherType } from "../../enums/cipher-type"; +import { CipherView } from "../../models/view/cipher.view"; +import { Fido2CredentialView } from "../../models/view/fido2-credential.view"; + +import { CBOR } from "./cbor"; +import { joseToDer } from "./ecdsa-utils"; +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat, guidToStandardFormat } from "./guid-utils"; + +// AAGUID: 6e8248d5-b479-40db-a3d8-11116f7e8349 +export const AAGUID = new Uint8Array([ + 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49, +]); + +const KeyUsages: KeyUsage[] = ["sign"]; + +/** + * Bitwarden implementation of the WebAuthn Authenticator Model as described by W3C + * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model + * + * It is highly recommended that the W3C specification is used a reference when reading this code. + */ +export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction { + constructor( + private cipherService: CipherService, + private userInterface: Fido2UserInterfaceService, + private syncService: SyncService, + private logService?: LogService + ) {} + + async makeCredential( + params: Fido2AuthenticatorMakeCredentialsParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ): Promise { + const userInterfaceSession = await this.userInterface.newSession( + params.fallbackSupported, + tab, + abortController + ); + + try { + if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) { + const requestedAlgorithms = params.credTypesAndPubKeyAlgs.map((p) => p.alg).join(", "); + this.logService?.warning( + `[Fido2Authenticator] No compatible algorithms found, RP requested: ${requestedAlgorithms}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotSupported); + } + + if ( + params.requireResidentKey != undefined && + typeof params.requireResidentKey !== "boolean" + ) { + this.logService?.error( + `[Fido2Authenticator] Invalid 'requireResidentKey' value: ${String( + params.requireResidentKey + )}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + if ( + params.requireUserVerification != undefined && + typeof params.requireUserVerification !== "boolean" + ) { + this.logService?.error( + `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String( + params.requireUserVerification + )}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + await userInterfaceSession.ensureUnlockedVault(); + await this.syncService.fullSync(false); + + const existingCipherIds = await this.findExcludedCredentials( + params.excludeCredentialDescriptorList + ); + if (existingCipherIds.length > 0) { + this.logService?.info( + `[Fido2Authenticator] Aborting due to excluded credential found in vault.` + ); + await userInterfaceSession.informExcludedCredential(existingCipherIds); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + let cipher: CipherView; + let fido2Credential: Fido2CredentialView; + let keyPair: CryptoKeyPair; + let userVerified = false; + let credentialId: string; + const response = await userInterfaceSession.confirmNewCredential({ + credentialName: params.rpEntity.name, + userName: params.userEntity.displayName, + userVerification: params.requireUserVerification, + }); + const cipherId = response.cipherId; + userVerified = response.userVerified; + + if (cipherId === undefined) { + this.logService?.warning( + `[Fido2Authenticator] Aborting because user confirmation was not recieved.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + try { + keyPair = await createKeyPair(); + + const encrypted = await this.cipherService.get(cipherId); + cipher = await encrypted.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(encrypted) + ); + + if ( + !userVerified && + (params.requireUserVerification || cipher.reprompt !== CipherRepromptType.None) + ) { + this.logService?.warning( + `[Fido2Authenticator] Aborting because user verification was unsuccessful.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + fido2Credential = await createKeyView(params, keyPair.privateKey); + cipher.login.fido2Credentials = [fido2Credential]; + const reencrypted = await this.cipherService.encrypt(cipher); + await this.cipherService.updateWithServer(reencrypted); + credentialId = fido2Credential.credentialId; + } catch (error) { + this.logService?.error( + `[Fido2Authenticator] Aborting because of unknown error when creating credential: ${error}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + const authData = await generateAuthData({ + rpId: params.rpEntity.id, + credentialId: guidToRawFormat(credentialId), + counter: fido2Credential.counter, + userPresence: true, + userVerification: userVerified, + keyPair, + }); + const attestationObject = new Uint8Array( + CBOR.encode({ + fmt: "none", + attStmt: {}, + authData, + }) + ); + + return { + credentialId: guidToRawFormat(credentialId), + attestationObject, + authData, + publicKeyAlgorithm: -7, + }; + } finally { + userInterfaceSession.close(); + } + } + + async getAssertion( + params: Fido2AuthenticatorGetAssertionParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ): Promise { + const userInterfaceSession = await this.userInterface.newSession( + params.fallbackSupported, + tab, + abortController + ); + try { + if ( + params.requireUserVerification != undefined && + typeof params.requireUserVerification !== "boolean" + ) { + this.logService?.error( + `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String( + params.requireUserVerification + )}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + let cipherOptions: CipherView[]; + + await userInterfaceSession.ensureUnlockedVault(); + await this.syncService.fullSync(false); + + if (params.allowCredentialDescriptorList?.length > 0) { + cipherOptions = await this.findCredentialsById( + params.allowCredentialDescriptorList, + params.rpId + ); + } else { + cipherOptions = await this.findCredentialsByRp(params.rpId); + } + + if (cipherOptions.length === 0) { + this.logService?.info( + `[Fido2Authenticator] Aborting because no matching credentials were found in the vault.` + ); + await userInterfaceSession.informCredentialNotFound(); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + const response = await userInterfaceSession.pickCredential({ + cipherIds: cipherOptions.map((cipher) => cipher.id), + userVerification: params.requireUserVerification, + }); + const selectedCipherId = response.cipherId; + const userVerified = response.userVerified; + const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId); + + if (selectedCipher === undefined) { + this.logService?.error( + `[Fido2Authenticator] Aborting because the selected credential could not be found.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + if ( + !userVerified && + (params.requireUserVerification || selectedCipher.reprompt !== CipherRepromptType.None) + ) { + this.logService?.warning( + `[Fido2Authenticator] Aborting because user verification was unsuccessful.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + try { + const selectedFido2Credential = selectedCipher.login.fido2Credentials[0]; + const selectedCredentialId = selectedFido2Credential.credentialId; + + ++selectedFido2Credential.counter; + + selectedCipher.localData = { + ...selectedCipher.localData, + lastUsedDate: new Date().getTime(), + }; + const encrypted = await this.cipherService.encrypt(selectedCipher); + await this.cipherService.updateWithServer(encrypted); + + const authenticatorData = await generateAuthData({ + rpId: selectedFido2Credential.rpId, + credentialId: guidToRawFormat(selectedCredentialId), + counter: selectedFido2Credential.counter, + userPresence: true, + userVerification: userVerified, + }); + + const signature = await generateSignature({ + authData: authenticatorData, + clientDataHash: params.hash, + privateKey: await getPrivateKeyFromFido2Credential(selectedFido2Credential), + }); + + return { + authenticatorData, + selectedCredential: { + id: guidToRawFormat(selectedCredentialId), + userHandle: Fido2Utils.stringToBuffer(selectedFido2Credential.userHandle), + }, + signature, + }; + } catch (error) { + this.logService?.error( + `[Fido2Authenticator] Aborting because of unknown error when asserting credential: ${error}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + } finally { + userInterfaceSession.close(); + } + } + + /** Finds existing crendetials and returns the `cipherId` for each one */ + private async findExcludedCredentials( + credentials: PublicKeyCredentialDescriptor[] + ): Promise { + const ids: string[] = []; + + for (const credential of credentials) { + try { + ids.push(guidToStandardFormat(credential.id)); + // eslint-disable-next-line no-empty + } catch {} + } + + if (ids.length === 0) { + return []; + } + + const ciphers = await this.cipherService.getAllDecrypted(); + return ciphers + .filter( + (cipher) => + !cipher.isDeleted && + cipher.organizationId == undefined && + cipher.type === CipherType.Login && + cipher.login.hasFido2Credentials && + ids.includes(cipher.login.fido2Credentials[0].credentialId) + ) + .map((cipher) => cipher.id); + } + + private async findCredentialsById( + credentials: PublicKeyCredentialDescriptor[], + rpId: string + ): Promise { + const ids: string[] = []; + + for (const credential of credentials) { + try { + ids.push(guidToStandardFormat(credential.id)); + // eslint-disable-next-line no-empty + } catch {} + } + + if (ids.length === 0) { + return []; + } + + const ciphers = await this.cipherService.getAllDecrypted(); + return ciphers.filter( + (cipher) => + !cipher.isDeleted && + cipher.type === CipherType.Login && + cipher.login.hasFido2Credentials && + cipher.login.fido2Credentials[0].rpId === rpId && + ids.includes(cipher.login.fido2Credentials[0].credentialId) + ); + } + + private async findCredentialsByRp(rpId: string): Promise { + const ciphers = await this.cipherService.getAllDecrypted(); + return ciphers.filter( + (cipher) => + !cipher.isDeleted && + cipher.type === CipherType.Login && + cipher.login.hasFido2Credentials && + cipher.login.fido2Credentials[0].rpId === rpId && + cipher.login.fido2Credentials[0].discoverable + ); + } +} + +async function createKeyPair() { + return await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + KeyUsages + ); +} + +async function createKeyView( + params: Fido2AuthenticatorMakeCredentialsParams, + keyValue: CryptoKey +): Promise { + if (keyValue.algorithm.name !== "ECDSA" && (keyValue.algorithm as any).namedCurve !== "P-256") { + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + const pkcs8Key = await crypto.subtle.exportKey("pkcs8", keyValue); + const fido2Credential = new Fido2CredentialView(); + fido2Credential.credentialId = Utils.newGuid(); + fido2Credential.keyType = "public-key"; + fido2Credential.keyAlgorithm = "ECDSA"; + fido2Credential.keyCurve = "P-256"; + fido2Credential.keyValue = Fido2Utils.bufferToString(pkcs8Key); + fido2Credential.rpId = params.rpEntity.id; + fido2Credential.userHandle = Fido2Utils.bufferToString(params.userEntity.id); + fido2Credential.counter = 0; + fido2Credential.rpName = params.rpEntity.name; + fido2Credential.userDisplayName = params.userEntity.displayName; + fido2Credential.discoverable = params.requireResidentKey; + fido2Credential.creationDate = new Date(); + + return fido2Credential; +} + +async function getPrivateKeyFromFido2Credential( + fido2Credential: Fido2CredentialView +): Promise { + const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue); + return await crypto.subtle.importKey( + "pkcs8", + keyBuffer, + { + name: fido2Credential.keyAlgorithm, + namedCurve: fido2Credential.keyCurve, + } as EcKeyImportParams, + true, + KeyUsages + ); +} + +interface AuthDataParams { + rpId: string; + credentialId: BufferSource; + userPresence: boolean; + userVerification: boolean; + counter: number; + keyPair?: CryptoKeyPair; +} + +async function generateAuthData(params: AuthDataParams) { + const authData: Array = []; + + const rpIdHash = new Uint8Array( + await crypto.subtle.digest({ name: "SHA-256" }, Utils.fromByteStringToArray(params.rpId)) + ); + authData.push(...rpIdHash); + + const flags = authDataFlags({ + extensionData: false, + attestationData: params.keyPair != undefined, + userVerification: params.userVerification, + userPresence: params.userPresence, + }); + authData.push(flags); + + // add 4 bytes of counter - we use time in epoch seconds as monotonic counter + // TODO: Consider changing this to a cryptographically safe random number + const counter = params.counter; + authData.push( + ((counter & 0xff000000) >> 24) & 0xff, + ((counter & 0x00ff0000) >> 16) & 0xff, + ((counter & 0x0000ff00) >> 8) & 0xff, + counter & 0x000000ff + ); + + if (params.keyPair) { + // attestedCredentialData + const attestedCredentialData: Array = []; + + attestedCredentialData.push(...AAGUID); + + // credentialIdLength (2 bytes) and credential Id + const rawId = Fido2Utils.bufferSourceToUint8Array(params.credentialId); + const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff]; + attestedCredentialData.push(...credentialIdLength); + attestedCredentialData.push(...rawId); + + const publicKeyJwk = await crypto.subtle.exportKey("jwk", params.keyPair.publicKey); + // COSE format of the EC256 key + const keyX = Utils.fromUrlB64ToArray(publicKeyJwk.x); + const keyY = Utils.fromUrlB64ToArray(publicKeyJwk.y); + + // Can't get `cbor-redux` to encode in CTAP2 canonical CBOR. So we do it manually: + const coseBytes = new Uint8Array(77); + coseBytes.set([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20], 0); + coseBytes.set(keyX, 10); + coseBytes.set([0x22, 0x58, 0x20], 10 + 32); + coseBytes.set(keyY, 10 + 32 + 3); + + // credential public key - convert to array from CBOR encoded COSE key + attestedCredentialData.push(...coseBytes); + + authData.push(...attestedCredentialData); + } + + return new Uint8Array(authData); +} + +interface SignatureParams { + authData: Uint8Array; + clientDataHash: BufferSource; + privateKey: CryptoKey; +} + +async function generateSignature(params: SignatureParams) { + const sigBase = new Uint8Array([ + ...params.authData, + ...Fido2Utils.bufferSourceToUint8Array(params.clientDataHash), + ]); + const p1336_signature = new Uint8Array( + await crypto.subtle.sign( + { + name: "ECDSA", + hash: { name: "SHA-256" }, + }, + params.privateKey, + sigBase + ) + ); + + const asn1Der_signature = joseToDer(p1336_signature, "ES256"); + + return asn1Der_signature; +} + +interface Flags { + extensionData: boolean; + attestationData: boolean; + userVerification: boolean; + userPresence: boolean; +} + +function authDataFlags(options: Flags): number { + let flags = 0; + + if (options.extensionData) { + flags |= 0b1000000; + } + + if (options.attestationData) { + flags |= 0b01000000; + } + + if (options.userVerification) { + flags |= 0b00000100; + } + + if (options.userPresence) { + flags |= 0b00000001; + } + + return flags; +} diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts new file mode 100644 index 00000000000..a8b2a071c14 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts @@ -0,0 +1,463 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { Utils } from "../../../platform/misc/utils"; +import { + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionResult, + Fido2AuthenticatorMakeCredentialResult, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { + AssertCredentialParams, + CreateCredentialParams, + FallbackRequestedError, +} from "../../abstractions/fido2/fido2-client.service.abstraction"; + +import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; +import { Fido2ClientService } from "./fido2-client.service"; +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat } from "./guid-utils"; + +const RpId = "bitwarden.com"; + +describe("FidoAuthenticatorService", () => { + let authenticator!: MockProxy; + let configService!: MockProxy; + let authService!: MockProxy; + let client!: Fido2ClientService; + let tab!: chrome.tabs.Tab; + + beforeEach(async () => { + authenticator = mock(); + configService = mock(); + authService = mock(); + + client = new Fido2ClientService(authenticator, configService, authService); + configService.getFeatureFlag.mockResolvedValue(true); + tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; + }); + + describe("createCredential", () => { + describe("input parameters validation", () => { + // Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException. + it("should throw error if sameOriginWithAncestors is false", async () => { + const params = createParams({ sameOriginWithAncestors: false }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. + it("should throw error if user.id is too small", async () => { + const params = createParams({ user: { id: "", displayName: "name" } }); + + const result = async () => await client.createCredential(params, tab); + + await expect(result).rejects.toBeInstanceOf(TypeError); + }); + + // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. + it("should throw error if user.id is too large", async () => { + const params = createParams({ + user: { + id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ", + displayName: "name", + }, + }); + + const result = async () => await client.createCredential(params, tab); + + await expect(result).rejects.toBeInstanceOf(TypeError); + }); + + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + // Not sure how to check this, or if it matters. + it.todo("should throw error if origin is an opaque origin"); + + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. + it("should throw error if origin is not a valid domain name", async () => { + const params = createParams({ + origin: "invalid-domain-name", + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. + it("should throw error if rp.id is not valid for this origin", async () => { + const params = createParams({ + origin: "https://passwordless.dev", + rp: { id: "bitwarden.com", name: "Bitwraden" }, + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw error if origin is not an https domain", async () => { + const params = createParams({ + origin: "http://passwordless.dev", + rp: { id: "bitwarden.com", name: "Bitwraden" }, + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm. + it("should throw error if no support key algorithms were found", async () => { + const params = createParams({ + pubKeyCredParams: [ + { alg: -9001, type: "public-key" }, + { alg: -7, type: "not-supported" as any }, + ], + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotSupportedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("aborting", () => { + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + it("should throw error if aborting using abort controller", async () => { + const params = createParams({}); + const abortController = new AbortController(); + abortController.abort(); + + const result = async () => await client.createCredential(params, tab, abortController); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "AbortError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("creating a new credential", () => { + it("should call authenticator.makeCredential", async () => { + const params = createParams({ + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + }); + authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); + + await client.createCredential(params, tab); + + expect(authenticator.makeCredential).toHaveBeenCalledWith( + expect.objectContaining({ + requireResidentKey: true, + requireUserVerification: true, + rpEntity: expect.objectContaining({ + id: RpId, + }), + userEntity: expect.objectContaining({ + displayName: params.user.displayName, + }), + }), + tab, + expect.anything() + ); + }); + + // Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm. + it("should throw error if authenticator throws InvalidState", async () => { + const params = createParams(); + authenticator.makeCredential.mockRejectedValue( + new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + ); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "InvalidStateError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // This keeps sensetive information form leaking + it("should throw NotAllowedError if authenticator throws unknown error", async () => { + const params = createParams(); + authenticator.makeCredential.mockRejectedValue(new Error("unknown error")); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw FallbackRequestedError if feature flag is not enabled", async () => { + const params = createParams(); + configService.getFeatureFlag.mockResolvedValue(false); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + + it("should throw FallbackRequestedError if user is logged out", async () => { + const params = createParams(); + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + }); + + function createParams(params: Partial = {}): CreateCredentialParams { + return { + origin: params.origin ?? "https://bitwarden.com", + sameOriginWithAncestors: params.sameOriginWithAncestors ?? true, + attestation: params.attestation, + authenticatorSelection: params.authenticatorSelection, + challenge: params.challenge ?? "MzItYnl0ZXMtYmFzZTY0LWVuY29kZS1jaGFsbGVuZ2U", + excludeCredentials: params.excludeCredentials, + extensions: params.extensions, + pubKeyCredParams: params.pubKeyCredParams ?? [ + { + alg: -7, + type: "public-key", + }, + ], + rp: params.rp ?? { + id: RpId, + name: "Bitwarden", + }, + user: params.user ?? { + id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA", + displayName: "User Name", + }, + fallbackSupported: params.fallbackSupported ?? false, + timeout: params.timeout, + }; + } + + function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult { + return { + credentialId: guidToRawFormat(Utils.newGuid()), + attestationObject: randomBytes(128), + authData: randomBytes(64), + publicKeyAlgorithm: -7, + }; + } + }); + + describe("assertCredential", () => { + describe("invalid params", () => { + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + // Not sure how to check this, or if it matters. + it.todo("should throw error if origin is an opaque origin"); + + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. + it("should throw error if origin is not a valid domain name", async () => { + const params = createParams({ + origin: "invalid-domain-name", + }); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. + it("should throw error if rp.id is not valid for this origin", async () => { + const params = createParams({ + origin: "https://passwordless.dev", + rpId: "bitwarden.com", + }); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw error if origin is not an http domain", async () => { + const params = createParams({ + origin: "http://passwordless.dev", + rpId: "bitwarden.com", + }); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("aborting", () => { + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + it("should throw error if aborting using abort controller", async () => { + const params = createParams({}); + const abortController = new AbortController(); + abortController.abort(); + + const result = async () => await client.assertCredential(params, tab, abortController); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "AbortError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("assert credential", () => { + // Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm. + it("should throw error if authenticator throws InvalidState", async () => { + const params = createParams(); + authenticator.getAssertion.mockRejectedValue( + new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + ); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "InvalidStateError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // This keeps sensetive information form leaking + it("should throw NotAllowedError if authenticator throws unknown error", async () => { + const params = createParams(); + authenticator.getAssertion.mockRejectedValue(new Error("unknown error")); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw FallbackRequestedError if feature flag is not enabled", async () => { + const params = createParams(); + configService.getFeatureFlag.mockResolvedValue(false); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + + it("should throw FallbackRequestedError if user is logged out", async () => { + const params = createParams(); + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + }); + + describe("assert non-discoverable credential", () => { + it("should call authenticator.assertCredential", async () => { + const allowedCredentialIds = [ + Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())), + Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())), + Fido2Utils.bufferToString(Utils.fromByteStringToArray("not-a-guid")), + ]; + const params = createParams({ + userVerification: "required", + allowedCredentialIds, + }); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + + await client.assertCredential(params, tab); + + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + requireUserVerification: true, + rpId: RpId, + allowCredentialDescriptorList: [ + expect.objectContaining({ + id: Fido2Utils.stringToBuffer(allowedCredentialIds[0]), + }), + expect.objectContaining({ + id: Fido2Utils.stringToBuffer(allowedCredentialIds[1]), + }), + expect.objectContaining({ + id: Fido2Utils.stringToBuffer(allowedCredentialIds[2]), + }), + ], + }), + tab, + expect.anything() + ); + }); + }); + + describe("assert discoverable credential", () => { + it("should call authenticator.assertCredential", async () => { + const params = createParams({ + userVerification: "required", + allowedCredentialIds: [], + }); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + + await client.assertCredential(params, tab); + + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + requireUserVerification: true, + rpId: RpId, + allowCredentialDescriptorList: [], + }), + tab, + expect.anything() + ); + }); + }); + + function createParams(params: Partial = {}): AssertCredentialParams { + return { + allowedCredentialIds: params.allowedCredentialIds ?? [], + challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)), + origin: params.origin ?? "https://bitwarden.com", + rpId: params.rpId ?? RpId, + timeout: params.timeout, + userVerification: params.userVerification, + sameOriginWithAncestors: true, + fallbackSupported: params.fallbackSupported ?? false, + }; + } + + function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult { + return { + selectedCredential: { + id: randomBytes(32), + userHandle: randomBytes(32), + }, + authenticatorData: randomBytes(64), + signature: randomBytes(64), + }; + } + }); +}); + +/** This is a fake function that always returns the same byte sequence */ +function randomBytes(length: number) { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts new file mode 100644 index 00000000000..16049fd08a6 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -0,0 +1,409 @@ +import { parse } from "tldts"; + +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { LogService } from "../../../platform/abstractions/log.service"; +import { Utils } from "../../../platform/misc/utils"; +import { + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService, + PublicKeyCredentialDescriptor, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { + AssertCredentialParams, + AssertCredentialResult, + CreateCredentialParams, + CreateCredentialResult, + FallbackRequestedError, + Fido2ClientService as Fido2ClientServiceAbstraction, + PublicKeyCredentialParam, + UserRequestedFallbackAbortReason, + UserVerification, +} from "../../abstractions/fido2/fido2-client.service.abstraction"; + +import { isValidRpId } from "./domain-utils"; +import { Fido2Utils } from "./fido2-utils"; + +/** + * Bitwarden implementation of the Web Authentication API as described by W3C + * https://www.w3.org/TR/webauthn-3/#sctn-api + * + * It is highly recommended that the W3C specification is used a reference when reading this code. + */ +export class Fido2ClientService implements Fido2ClientServiceAbstraction { + constructor( + private authenticator: Fido2AuthenticatorService, + private configService: ConfigServiceAbstraction, + private authService: AuthService, + private logService?: LogService + ) {} + + async isFido2FeatureEnabled(): Promise { + return await this.configService.getFeatureFlag(FeatureFlag.Fido2VaultCredentials); + } + + async createCredential( + params: CreateCredentialParams, + tab: chrome.tabs.Tab, + abortController = new AbortController() + ): Promise { + const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(); + + if (!enableFido2VaultCredentials) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + const authStatus = await this.authService.getAuthStatus(); + + if (authStatus === AuthenticationStatus.LoggedOut) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + if (!params.sameOriginWithAncestors) { + this.logService?.warning( + `[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}` + ); + throw new DOMException("Invalid 'sameOriginWithAncestors' value", "NotAllowedError"); + } + + const userId = Fido2Utils.stringToBuffer(params.user.id); + if (userId.length < 1 || userId.length > 64) { + this.logService?.warning( + `[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})` + ); + throw new TypeError("Invalid 'user.id' length"); + } + + const parsedOrigin = parse(params.origin, { allowPrivateDomains: true }); + params.rp.id = params.rp.id ?? parsedOrigin.hostname; + + if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) { + this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`); + throw new DOMException("'origin' is not a valid https origin", "SecurityError"); + } + + if (!isValidRpId(params.rp.id, params.origin)) { + this.logService?.warning( + `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}` + ); + throw new DOMException("'rp.id' cannot be used with the current origin", "SecurityError"); + } + + let credTypesAndPubKeyAlgs: PublicKeyCredentialParam[]; + if (params.pubKeyCredParams?.length > 0) { + // Filter out all unsupported algorithms + credTypesAndPubKeyAlgs = params.pubKeyCredParams.filter( + (kp) => kp.alg === -7 && kp.type === "public-key" + ); + } else { + // Assign default algorithms + credTypesAndPubKeyAlgs = [ + { alg: -7, type: "public-key" }, + { alg: -257, type: "public-key" }, + ]; + } + + if (credTypesAndPubKeyAlgs.length === 0) { + const requestedAlgorithms = credTypesAndPubKeyAlgs.map((p) => p.alg).join(", "); + this.logService?.warning( + `[Fido2Client] No compatible algorithms found, RP requested: ${requestedAlgorithms}` + ); + throw new DOMException("No supported key algorithms were found", "NotSupportedError"); + } + + const collectedClientData = { + type: "webauthn.create", + challenge: params.challenge, + origin: params.origin, + crossOrigin: !params.sameOriginWithAncestors, + // tokenBinding: {} // Not currently supported + }; + const clientDataJSON = JSON.stringify(collectedClientData); + const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const makeCredentialParams = mapToMakeCredentialParams({ + params, + credTypesAndPubKeyAlgs, + clientDataHash, + }); + + // Set timeout before invoking authenticator + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + const timeout = setAbortTimeout( + abortController, + params.authenticatorSelection?.userVerification, + params.timeout + ); + + let makeCredentialResult; + try { + makeCredentialResult = await this.authenticator.makeCredential( + makeCredentialParams, + tab, + abortController + ); + } catch (error) { + if ( + abortController.signal.aborted && + abortController.signal.reason === UserRequestedFallbackAbortReason + ) { + this.logService?.info(`[Fido2Client] Aborting because user requested fallback`); + throw new FallbackRequestedError(); + } + + if ( + error instanceof Fido2AutenticatorError && + error.errorCode === Fido2AutenticatorErrorCode.InvalidState + ) { + this.logService?.warning(`[Fido2Client] Unknown error: ${error}`); + throw new DOMException("Unknown error occured.", "InvalidStateError"); + } + + this.logService?.info(`[Fido2Client] Aborted by user: ${error}`); + throw new DOMException( + "The operation either timed out or was not allowed.", + "NotAllowedError" + ); + } + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + + clearTimeout(timeout); + return { + credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId), + attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject), + authData: Fido2Utils.bufferToString(makeCredentialResult.authData), + clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes), + publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm, + transports: ["internal"], + }; + } + + async assertCredential( + params: AssertCredentialParams, + tab: chrome.tabs.Tab, + abortController = new AbortController() + ): Promise { + const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(); + + if (!enableFido2VaultCredentials) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + const authStatus = await this.authService.getAuthStatus(); + + if (authStatus === AuthenticationStatus.LoggedOut) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + const { domain: effectiveDomain } = parse(params.origin, { allowPrivateDomains: true }); + if (effectiveDomain == undefined) { + this.logService?.warning(`[Fido2Client] Invalid origin: ${params.origin}`); + throw new DOMException("'origin' is not a valid domain", "SecurityError"); + } + + const parsedOrigin = parse(params.origin, { allowPrivateDomains: true }); + params.rpId = params.rpId ?? parsedOrigin.hostname; + + if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) { + this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`); + throw new DOMException("'origin' is not a valid https origin", "SecurityError"); + } + + if (!isValidRpId(params.rpId, params.origin)) { + this.logService?.warning( + `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}` + ); + throw new DOMException("'rp.id' cannot be used with the current origin", "SecurityError"); + } + + const collectedClientData = { + type: "webauthn.get", + challenge: params.challenge, + origin: params.origin, + crossOrigin: !params.sameOriginWithAncestors, + // tokenBinding: {} // Not currently supported + }; + const clientDataJSON = JSON.stringify(collectedClientData); + const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const getAssertionParams = mapToGetAssertionParams({ params, clientDataHash }); + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + + const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout); + + let getAssertionResult; + try { + getAssertionResult = await this.authenticator.getAssertion( + getAssertionParams, + tab, + abortController + ); + } catch (error) { + if ( + abortController.signal.aborted && + abortController.signal.reason === UserRequestedFallbackAbortReason + ) { + this.logService?.info(`[Fido2Client] Aborting because user requested fallback`); + throw new FallbackRequestedError(); + } + + if ( + error instanceof Fido2AutenticatorError && + error.errorCode === Fido2AutenticatorErrorCode.InvalidState + ) { + this.logService?.warning(`[Fido2Client] Unknown error: ${error}`); + throw new DOMException("Unknown error occured.", "InvalidStateError"); + } + + this.logService?.info(`[Fido2Client] Aborted by user: ${error}`); + throw new DOMException( + "The operation either timed out or was not allowed.", + "NotAllowedError" + ); + } + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + clearTimeout(timeout); + + return { + authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData), + clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes), + credentialId: Fido2Utils.bufferToString(getAssertionResult.selectedCredential.id), + userHandle: + getAssertionResult.selectedCredential.userHandle !== undefined + ? Fido2Utils.bufferToString(getAssertionResult.selectedCredential.userHandle) + : undefined, + signature: Fido2Utils.bufferToString(getAssertionResult.signature), + }; + } +} + +const TIMEOUTS = { + NO_VERIFICATION: { + DEFAULT: 120000, + MIN: 30000, + MAX: 180000, + }, + WITH_VERIFICATION: { + DEFAULT: 300000, + MIN: 30000, + MAX: 600000, + }, +}; + +function setAbortTimeout( + abortController: AbortController, + userVerification?: UserVerification, + timeout?: number +): number { + let clampedTimeout: number; + + if (userVerification === "required") { + timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT; + clampedTimeout = Math.max( + TIMEOUTS.WITH_VERIFICATION.MIN, + Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX) + ); + } else { + timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT; + clampedTimeout = Math.max( + TIMEOUTS.NO_VERIFICATION.MIN, + Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX) + ); + } + + return window.setTimeout(() => abortController.abort(), clampedTimeout); +} + +/** + * Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator. + */ +function mapToMakeCredentialParams({ + params, + credTypesAndPubKeyAlgs, + clientDataHash, +}: { + params: CreateCredentialParams; + credTypesAndPubKeyAlgs: PublicKeyCredentialParam[]; + clientDataHash: ArrayBuffer; +}): Fido2AuthenticatorMakeCredentialsParams { + const excludeCredentialDescriptorList: PublicKeyCredentialDescriptor[] = + params.excludeCredentials?.map((credential) => ({ + id: Fido2Utils.stringToBuffer(credential.id), + transports: credential.transports, + type: credential.type, + })) ?? []; + + const requireResidentKey = + params.authenticatorSelection?.residentKey === "required" || + params.authenticatorSelection?.residentKey === "preferred" || + (params.authenticatorSelection?.residentKey === undefined && + params.authenticatorSelection?.requireResidentKey === true); + + return { + requireResidentKey, + requireUserVerification: params.authenticatorSelection?.userVerification === "required", + enterpriseAttestationPossible: params.attestation === "enterprise", + excludeCredentialDescriptorList, + credTypesAndPubKeyAlgs, + hash: clientDataHash, + rpEntity: { + id: params.rp.id, + name: params.rp.name, + }, + userEntity: { + id: Fido2Utils.stringToBuffer(params.user.id), + displayName: params.user.displayName, + }, + fallbackSupported: params.fallbackSupported, + }; +} + +/** + * Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator. + */ +function mapToGetAssertionParams({ + params, + clientDataHash, +}: { + params: AssertCredentialParams; + clientDataHash: ArrayBuffer; +}): Fido2AuthenticatorGetAssertionParams { + const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] = + params.allowedCredentialIds.map((id) => ({ + id: Fido2Utils.stringToBuffer(id), + type: "public-key", + })); + + return { + rpId: params.rpId, + requireUserVerification: params.userVerification === "required", + hash: clientDataHash, + allowCredentialDescriptorList, + extensions: {}, + fallbackSupported: params.fallbackSupported, + }; +} diff --git a/libs/common/src/vault/services/fido2/fido2-utils.ts b/libs/common/src/vault/services/fido2/fido2-utils.ts new file mode 100644 index 00000000000..a2de1375507 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-utils.ts @@ -0,0 +1,26 @@ +import { Utils } from "../../../platform/misc/utils"; + +export class Fido2Utils { + static bufferToString(bufferSource: BufferSource): string { + const buffer = Fido2Utils.bufferSourceToUint8Array(bufferSource); + + return Utils.fromBufferToUrlB64(buffer); + } + + static stringToBuffer(str: string): Uint8Array { + return Utils.fromUrlB64ToArray(str); + } + + static bufferSourceToUint8Array(bufferSource: BufferSource) { + if (Fido2Utils.isArrayBuffer(bufferSource)) { + return new Uint8Array(bufferSource); + } else { + return new Uint8Array(bufferSource.buffer); + } + } + + /** Utility function to identify type of bufferSource. Necessary because of differences between runtimes */ + private static isArrayBuffer(bufferSource: BufferSource): bufferSource is ArrayBuffer { + return bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined; + } +} diff --git a/libs/common/src/vault/services/fido2/guid-utils.ts b/libs/common/src/vault/services/fido2/guid-utils.ts new file mode 100644 index 00000000000..66e6cbb1d7c --- /dev/null +++ b/libs/common/src/vault/services/fido2/guid-utils.ts @@ -0,0 +1,95 @@ +/* + License for: guidToRawFormat, guidToStandardFormat + Source: https://github.com/uuidjs/uuid/ + The MIT License (MIT) + Copyright (c) 2010-2020 Robert Kieffer and other contributors + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/ + +import { Utils } from "../../../platform/misc/utils"; + +/** Private array used for optimization */ +const byteToHex = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).substring(1)); + +/** Convert standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID to raw 16 byte array. */ +export function guidToRawFormat(guid: string) { + if (!Utils.isGuid(guid)) { + throw TypeError("GUID parameter is invalid"); + } + + let v; + const arr = new Uint8Array(16); + + // Parse ########-....-....-....-............ + arr[0] = (v = parseInt(guid.slice(0, 8), 16)) >>> 24; + arr[1] = (v >>> 16) & 0xff; + arr[2] = (v >>> 8) & 0xff; + arr[3] = v & 0xff; + + // Parse ........-####-....-....-............ + arr[4] = (v = parseInt(guid.slice(9, 13), 16)) >>> 8; + arr[5] = v & 0xff; + + // Parse ........-....-####-....-............ + arr[6] = (v = parseInt(guid.slice(14, 18), 16)) >>> 8; + arr[7] = v & 0xff; + + // Parse ........-....-....-####-............ + arr[8] = (v = parseInt(guid.slice(19, 23), 16)) >>> 8; + arr[9] = v & 0xff; + + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + arr[10] = ((v = parseInt(guid.slice(24, 36), 16)) / 0x10000000000) & 0xff; + arr[11] = (v / 0x100000000) & 0xff; + arr[12] = (v >>> 24) & 0xff; + arr[13] = (v >>> 16) & 0xff; + arr[14] = (v >>> 8) & 0xff; + arr[15] = v & 0xff; + + return arr; +} + +/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */ +export function guidToStandardFormat(bufferSource: BufferSource) { + const arr = + bufferSource instanceof ArrayBuffer + ? new Uint8Array(bufferSource) + : new Uint8Array(bufferSource.buffer); + // Note: Be careful editing this code! It's been tuned for performance + // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 + const guid = ( + byteToHex[arr[0]] + + byteToHex[arr[1]] + + byteToHex[arr[2]] + + byteToHex[arr[3]] + + "-" + + byteToHex[arr[4]] + + byteToHex[arr[5]] + + "-" + + byteToHex[arr[6]] + + byteToHex[arr[7]] + + "-" + + byteToHex[arr[8]] + + byteToHex[arr[9]] + + "-" + + byteToHex[arr[10]] + + byteToHex[arr[11]] + + byteToHex[arr[12]] + + byteToHex[arr[13]] + + byteToHex[arr[14]] + + byteToHex[arr[15]] + ).toLowerCase(); + + // Consistency check for valid UUID. If this throws, it's likely due to one + // of the following: + // - One or more input array values don't map to a hex octet (leading to + // "undefined" in the uuid) + // - Invalid input values for the RFC `version` or `variant` fields + if (!Utils.isGuid(guid)) { + throw TypeError("Converted GUID is invalid"); + } + + return guid; +} diff --git a/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts b/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts new file mode 100644 index 00000000000..440bd519002 --- /dev/null +++ b/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts @@ -0,0 +1,14 @@ +import { + Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, + Fido2UserInterfaceSession, +} from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; + +/** + * Noop implementation of the {@link Fido2UserInterfaceService}. + * This implementation does not provide any user interface. + */ +export class Fido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { + newSession(): Promise { + throw new Error("Not implemented exception"); + } +} diff --git a/package-lock.json b/package-lock.json index 70cf905b80f..cdff4a8c214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "bootstrap": "4.6.0", "braintree-web-drop-in": "1.40.0", "bufferutil": "4.0.7", + "cbor-redux": "^0.4.0", "chalk": "4.1.2", "commander": "7.2.0", "core-js": "3.32.0", @@ -17669,6 +17670,11 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, + "node_modules/cbor-redux": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cbor-redux/-/cbor-redux-0.4.0.tgz", + "integrity": "sha512-jP8BB9zF2uVTwbNXe7kRNIQRmKFMNKZcx0A+TCc6v3kJHoIKzKexQ+DjvXP/G5HuPF88myDdVE2grBOizsbMxg==" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", From 20a9abbe196b4c1f0dd01cdf3474f130642994a3 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Tue, 17 Oct 2023 15:30:53 -0500 Subject: [PATCH 24/85] [PM-4366] Update popout windows to open as a `popup` window type rather than a `normal` window type (#6600) * [PM-4366] Update popout windows to open as a `popup` window type rather than a `normal` window type * [PM-4366] Update popout windows to open as a `popup` window type rather than a `normal` window type --- .../src/platform/popup/browser-popout-window.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts index f72f7bfb3e0..2d661780ce0 100644 --- a/apps/browser/src/platform/popup/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -7,10 +7,10 @@ import { BrowserPopoutWindowService as BrowserPopupWindowServiceInterface } from class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { private singleActionPopoutTabIds: Record = {}; private defaultPopoutWindowOptions: chrome.windows.CreateData = { - type: "normal", + type: "popup", focused: true, - width: 500, - height: 800, + width: 380, + height: 630, }; async openUnlockPrompt(senderWindowId: number) { From 2850a6723ae2827cad8acb17a5666d2402c3d788 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 16:44:07 -0400 Subject: [PATCH 25/85] Bumped desktop version to 2023.9.4 (#6614) Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com> --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- package-lock.json | 8 +------- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 68d2cc0f87f..c5d60670480 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2023.9.3", + "version": "2023.9.4", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index bf2744cd873..58fbe05af1a 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2023.9.3", + "version": "2023.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2023.9.3", + "version": "2023.9.4", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-native": "file:../desktop_native" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 9421f7a5277..8c228b27223 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2023.9.3", + "version": "2023.9.4", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index cdff4a8c214..3d09d1999e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,6 @@ "bootstrap": "4.6.0", "braintree-web-drop-in": "1.40.0", "bufferutil": "4.0.7", - "cbor-redux": "^0.4.0", "chalk": "4.1.2", "commander": "7.2.0", "core-js": "3.32.0", @@ -229,7 +228,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2023.9.3", + "version": "2023.9.4", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -17670,11 +17669,6 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, - "node_modules/cbor-redux": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/cbor-redux/-/cbor-redux-0.4.0.tgz", - "integrity": "sha512-jP8BB9zF2uVTwbNXe7kRNIQRmKFMNKZcx0A+TCc6v3kJHoIKzKexQ+DjvXP/G5HuPF88myDdVE2grBOizsbMxg==" - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", From 82553ebb13a5a24a444f31b98ced5730d71dd6e1 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Wed, 18 Oct 2023 12:14:16 -0400 Subject: [PATCH 26/85] fix typo (#6627) --- ...fido2-authenticator.service.abstraction.ts | 6 ++-- .../fido2/fido2-authenticator.service.spec.ts | 30 +++++++++---------- .../fido2/fido2-authenticator.service.ts | 30 +++++++++---------- .../fido2/fido2-client.service.spec.ts | 8 ++--- .../services/fido2/fido2-client.service.ts | 12 ++++---- 5 files changed, 43 insertions(+), 43 deletions(-) diff --git a/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts index 5a406aeb14c..671c6cb9fe1 100644 --- a/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -39,7 +39,7 @@ export enum Fido2AlgorithmIdentifier { RS256 = -257, } -export enum Fido2AutenticatorErrorCode { +export enum Fido2AuthenticatorErrorCode { Unknown = "UnknownError", NotSupported = "NotSupportedError", InvalidState = "InvalidStateError", @@ -47,8 +47,8 @@ export enum Fido2AutenticatorErrorCode { Constraint = "ConstraintError", } -export class Fido2AutenticatorError extends Error { - constructor(readonly errorCode: Fido2AutenticatorErrorCode) { +export class Fido2AuthenticatorError extends Error { + constructor(readonly errorCode: Fido2AuthenticatorErrorCode) { super(errorCode); } } diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts index c519fccffc2..ecf6f03d7e4 100644 --- a/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts @@ -5,7 +5,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { Utils } from "../../../platform/misc/utils"; import { CipherService } from "../../abstractions/cipher.service"; import { - Fido2AutenticatorErrorCode, + Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionParams, Fido2AuthenticatorMakeCredentialsParams, } from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; @@ -60,19 +60,19 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotSupported); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotSupported); }); it("should throw error when requireResidentKey has invalid value", async () => { const result = async () => await authenticator.makeCredential(invalidParams.invalidRk, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); it("should throw error when requireUserVerification has invalid value", async () => { const result = async () => await authenticator.makeCredential(invalidParams.invalidUv, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); /** @@ -85,7 +85,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.makeCredential(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Constraint); }); it("should not request confirmation from user", async () => { @@ -151,7 +151,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.makeCredential(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); /** Devation: Organization ciphers are not checked against excluded credentials, even if the user has access to them. */ @@ -267,7 +267,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.makeCredential(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); it("should throw error if user verification fails and cipher requires reprompt", async () => { @@ -281,7 +281,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.makeCredential(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); /** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */ @@ -296,7 +296,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.makeCredential(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); }); @@ -434,7 +434,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error when requireUserVerification has invalid value", async () => { const result = async () => await authenticator.getAssertion(invalidParams.invalidUv, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); /** @@ -447,7 +447,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.getAssertion(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Constraint); }); }); @@ -512,7 +512,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error", async () => { const result = async () => await authenticator.getAssertion(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); }); @@ -600,7 +600,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.getAssertion(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); it("should throw error if user verification fails and cipher requires reprompt", async () => { @@ -612,7 +612,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.getAssertion(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.NotAllowed); }); }); @@ -737,7 +737,7 @@ describe("FidoAuthenticatorService", () => { const result = async () => await authenticator.getAssertion(params, tab); - await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + await expect(result).rejects.toThrowError(Fido2AuthenticatorErrorCode.Unknown); }); }); diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts index e009d8b18ca..bef5616ca76 100644 --- a/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts @@ -3,8 +3,8 @@ import { Utils } from "../../../platform/misc/utils"; import { CipherService } from "../../abstractions/cipher.service"; import { Fido2AlgorithmIdentifier, - Fido2AutenticatorError, - Fido2AutenticatorErrorCode, + Fido2AuthenticatorError, + Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionParams, Fido2AuthenticatorGetAssertionResult, Fido2AuthenticatorMakeCredentialResult, @@ -62,7 +62,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.warning( `[Fido2Authenticator] No compatible algorithms found, RP requested: ${requestedAlgorithms}` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotSupported); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotSupported); } if ( @@ -74,7 +74,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr params.requireResidentKey )}` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } if ( @@ -86,7 +86,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr params.requireUserVerification )}` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } await userInterfaceSession.ensureUnlockedVault(); @@ -100,7 +100,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr `[Fido2Authenticator] Aborting due to excluded credential found in vault.` ); await userInterfaceSession.informExcludedCredential(existingCipherIds); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } let cipher: CipherView; @@ -120,7 +120,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.warning( `[Fido2Authenticator] Aborting because user confirmation was not recieved.` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } try { @@ -138,7 +138,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.warning( `[Fido2Authenticator] Aborting because user verification was unsuccessful.` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } fido2Credential = await createKeyView(params, keyPair.privateKey); @@ -150,7 +150,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.error( `[Fido2Authenticator] Aborting because of unknown error when creating credential: ${error}` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } const authData = await generateAuthData({ @@ -200,7 +200,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr params.requireUserVerification )}` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } let cipherOptions: CipherView[]; @@ -222,7 +222,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr `[Fido2Authenticator] Aborting because no matching credentials were found in the vault.` ); await userInterfaceSession.informCredentialNotFound(); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } const response = await userInterfaceSession.pickCredential({ @@ -237,7 +237,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.error( `[Fido2Authenticator] Aborting because the selected credential could not be found.` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } if ( @@ -247,7 +247,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.warning( `[Fido2Authenticator] Aborting because user verification was unsuccessful.` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.NotAllowed); } try { @@ -289,7 +289,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr this.logService?.error( `[Fido2Authenticator] Aborting because of unknown error when asserting credential: ${error}` ); - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } } finally { userInterfaceSession.close(); @@ -383,7 +383,7 @@ async function createKeyView( keyValue: CryptoKey ): Promise { if (keyValue.algorithm.name !== "ECDSA" && (keyValue.algorithm as any).namedCurve !== "P-256") { - throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + throw new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.Unknown); } const pkcs8Key = await crypto.subtle.exportKey("pkcs8", keyValue); diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts index a8b2a071c14..3bab5da102e 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts @@ -5,8 +5,8 @@ import { AuthenticationStatus } from "../../../auth/enums/authentication-status" import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; import { Utils } from "../../../platform/misc/utils"; import { - Fido2AutenticatorError, - Fido2AutenticatorErrorCode, + Fido2AuthenticatorError, + Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionResult, Fido2AuthenticatorMakeCredentialResult, } from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; @@ -181,7 +181,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error if authenticator throws InvalidState", async () => { const params = createParams(); authenticator.makeCredential.mockRejectedValue( - new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState) ); const result = async () => await client.createCredential(params, tab); @@ -329,7 +329,7 @@ describe("FidoAuthenticatorService", () => { it("should throw error if authenticator throws InvalidState", async () => { const params = createParams(); authenticator.getAssertion.mockRejectedValue( - new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + new Fido2AuthenticatorError(Fido2AuthenticatorErrorCode.InvalidState) ); const result = async () => await client.assertCredential(params, tab); diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts index 16049fd08a6..4c7eaf00a79 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -7,8 +7,8 @@ import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/ import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { - Fido2AutenticatorError, - Fido2AutenticatorErrorCode, + Fido2AuthenticatorError, + Fido2AuthenticatorErrorCode, Fido2AuthenticatorGetAssertionParams, Fido2AuthenticatorMakeCredentialsParams, Fido2AuthenticatorService, @@ -162,8 +162,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { } if ( - error instanceof Fido2AutenticatorError && - error.errorCode === Fido2AutenticatorErrorCode.InvalidState + error instanceof Fido2AuthenticatorError && + error.errorCode === Fido2AuthenticatorErrorCode.InvalidState ) { this.logService?.warning(`[Fido2Client] Unknown error: ${error}`); throw new DOMException("Unknown error occured.", "InvalidStateError"); @@ -268,8 +268,8 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { } if ( - error instanceof Fido2AutenticatorError && - error.errorCode === Fido2AutenticatorErrorCode.InvalidState + error instanceof Fido2AuthenticatorError && + error.errorCode === Fido2AuthenticatorErrorCode.InvalidState ) { this.logService?.warning(`[Fido2Client] Unknown error: ${error}`); throw new DOMException("Unknown error occured.", "InvalidStateError"); From c145763dedf40a62abe7bdab645c40ace8072401 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Oct 2023 12:23:26 -0400 Subject: [PATCH 27/85] Update bitwarden/gh-actions digest to c970b0f (#6529) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/brew-bump-cli.yml | 2 +- .github/workflows/brew-bump-desktop.yml | 2 +- .github/workflows/build-browser.yml | 4 ++-- .github/workflows/build-cli.yml | 2 +- .github/workflows/build-desktop.yml | 8 +++---- .github/workflows/build-web.yml | 6 ++--- .github/workflows/crowdin-pull.yml | 4 ++-- .github/workflows/deploy-eu-prod-web.yml | 4 ++-- .github/workflows/deploy-eu-qa-web.yml | 4 ++-- .github/workflows/deploy-non-prod-web.yml | 2 +- .github/workflows/release-browser.yml | 6 ++--- .github/workflows/release-cli.yml | 24 ++++++++++---------- .github/workflows/release-desktop-beta.yml | 8 +++---- .github/workflows/release-desktop.yml | 22 +++++++++--------- .github/workflows/release-web.yml | 12 +++++----- .github/workflows/staged-rollout-desktop.yml | 2 +- .github/workflows/version-bump.yml | 6 ++--- .github/workflows/workflow-linter.yml | 2 +- 18 files changed, 60 insertions(+), 60 deletions(-) diff --git a/.github/workflows/brew-bump-cli.yml b/.github/workflows/brew-bump-cli.yml index 8273ab00e80..4663e5079b9 100644 --- a/.github/workflows/brew-bump-cli.yml +++ b/.github/workflows/brew-bump-cli.yml @@ -23,7 +23,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "brew-bump-workflow-pat" diff --git a/.github/workflows/brew-bump-desktop.yml b/.github/workflows/brew-bump-desktop.yml index 4032f5883a0..03a46970d28 100644 --- a/.github/workflows/brew-bump-desktop.yml +++ b/.github/workflows/brew-bump-desktop.yml @@ -23,7 +23,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "brew-bump-workflow-pat" diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index f2441c79536..becb3b9bf3b 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -375,7 +375,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -437,7 +437,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index ffffdccb8a7..978ef26ea80 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -415,7 +415,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8b0482c3671..d55a6d13648 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -287,7 +287,7 @@ jobs: node-gyp install $(node -v) - name: Install AST - uses: bitwarden/gh-actions/install-ast@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/install-ast@c970b0fb89bd966749280e832928db62040812bf - name: Set up environmentF run: choco install checksum --no-progress @@ -312,7 +312,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "code-signing-vault-url, @@ -1204,7 +1204,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -1283,7 +1283,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index d0360b4a4ed..af591c8f3c3 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -197,7 +197,7 @@ jobs: - name: Retrieve github PAT secrets id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" @@ -273,7 +273,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -334,7 +334,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 0ff79d68296..d63682df68d 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -32,13 +32,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase" - name: Download translations - uses: bitwarden/gh-actions/crowdin@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/crowdin@c970b0fb89bd966749280e832928db62040812bf env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/deploy-eu-prod-web.yml b/.github/workflows/deploy-eu-prod-web.yml index aeb5d2c0197..569cd155e66 100644 --- a/.github/workflows/deploy-eu-prod-web.yml +++ b/.github/workflows/deploy-eu-prod-web.yml @@ -24,13 +24,13 @@ jobs: - name: Retrieve Storage Account connection string id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: webvault-westeurope-prod secrets: "sa-bitwarden-web-vault-dev-key-temp" - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: apps/web diff --git a/.github/workflows/deploy-eu-qa-web.yml b/.github/workflows/deploy-eu-qa-web.yml index 0b71fbc9981..bebf50d45e5 100644 --- a/.github/workflows/deploy-eu-qa-web.yml +++ b/.github/workflows/deploy-eu-qa-web.yml @@ -24,13 +24,13 @@ jobs: - name: Retrieve Storage Account connection string id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: webvaulteu-westeurope-qa secrets: "sa-bitwarden-web-vault-dev-key-temp" - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: apps/web diff --git a/.github/workflows/deploy-non-prod-web.yml b/.github/workflows/deploy-non-prod-web.yml index 8e5b8f5c9f3..47f0e0ff0a9 100644 --- a/.github/workflows/deploy-non-prod-web.yml +++ b/.github/workflows/deploy-non-prod-web.yml @@ -67,7 +67,7 @@ jobs: uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: apps/web diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 32cf8d18546..c55aaf2401c 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -41,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/release-version-check@c970b0fb89bd966749280e832928db62040812bf with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -103,7 +103,7 @@ jobs: - name: Download latest Release build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-browser.yml workflow_conclusion: success @@ -116,7 +116,7 @@ jobs: - name: Dry Run - Download latest master build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 04a8a04f4f5..e74bae7f818 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -57,7 +57,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/release-version-check@c970b0fb89bd966749280e832928db62040812bf with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -78,7 +78,7 @@ jobs: - name: Download all Release artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli @@ -87,7 +87,7 @@ jobs: - name: Dry Run - Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli @@ -150,7 +150,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" @@ -160,7 +160,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli @@ -170,7 +170,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli @@ -204,7 +204,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" @@ -220,7 +220,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli/dist @@ -230,7 +230,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli/dist @@ -263,14 +263,14 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "npm-api-key" - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli/build @@ -280,7 +280,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-cli.yml path: apps/cli/build diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index b2e568632b5..10ef295f615 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -48,7 +48,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/release-version-check@c970b0fb89bd966749280e832928db62040812bf with: release-type: 'Initial Release' project-type: ts @@ -241,7 +241,7 @@ jobs: node-gyp install $(node -v) - name: Install AST - uses: bitwarden/gh-actions/install-ast@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/install-ast@c970b0fb89bd966749280e832928db62040812bf - name: Set up environment run: choco install checksum --no-progress @@ -259,7 +259,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "code-signing-vault-url, @@ -945,7 +945,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index b9ffa80e512..03a3a7c41b5 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -67,7 +67,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/release-version-check@c970b0fb89bd966749280e832928db62040812bf with: release-type: ${{ inputs.release_type }} project-type: ts @@ -110,7 +110,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, @@ -123,7 +123,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-desktop.yml workflow_conclusion: success @@ -132,7 +132,7 @@ jobs: - name: Dry Run - Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-desktop.yml workflow_conclusion: success @@ -185,7 +185,7 @@ jobs: --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com - name: Get checksum files - uses: bitwarden/gh-actions/get-checksum@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-checksum@c970b0fb89bd966749280e832928db62040812bf with: packages_dir: "apps/desktop/artifacts" file_path: "apps/desktop/artifacts/sha256-checksums.txt" @@ -263,7 +263,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" @@ -277,7 +277,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-desktop.yml workflow_conclusion: success @@ -287,7 +287,7 @@ jobs: - name: Dry Run - Download Snap artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-desktop.yml workflow_conclusion: success @@ -327,7 +327,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" @@ -345,7 +345,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-desktop.yml workflow_conclusion: success @@ -355,7 +355,7 @@ jobs: - name: Dry Run - Download choco artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-desktop.yml workflow_conclusion: success diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index cbf83dc9cdf..e440929801d 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -41,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/release-version-check@c970b0fb89bd966749280e832928db62040812bf with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -130,7 +130,7 @@ jobs: - name: Retrieve bot secrets id: retrieve-bot-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: bitwarden-ci secrets: "github-pat-bitwarden-devops-bot-repo-scope" @@ -144,7 +144,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: assets @@ -154,7 +154,7 @@ jobs: - name: Dry Run - Download latest cloud asset if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: assets @@ -227,7 +227,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: apps/web/artifacts @@ -238,7 +238,7 @@ jobs: - name: Dry Run - Download latest build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/download-artifacts@c970b0fb89bd966749280e832928db62040812bf with: workflow: build-web.yml path: apps/web/artifacts diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index a3eacb868fd..7f0e428ffb8 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -26,7 +26,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 14755097b0e..ebacacecfe2 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -54,7 +54,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/get-keyvault-secrets@c970b0fb89bd966749280e832928db62040812bf with: keyvault: "bitwarden-ci" secrets: "github-gpg-private-key, github-gpg-private-key-passphrase" @@ -125,14 +125,14 @@ jobs: - name: Bump Browser Version - Manifest if: ${{ inputs.bump_browser == true }} - uses: bitwarden/gh-actions/version-bump@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/version-bump@c970b0fb89bd966749280e832928db62040812bf with: version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.json" - name: Bump Browser Version - Manifest v3 if: ${{ inputs.bump_browser == true }} - uses: bitwarden/gh-actions/version-bump@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/version-bump@c970b0fb89bd966749280e832928db62040812bf with: version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.v3.json" diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 49388c11f82..5afd077e8d2 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -8,4 +8,4 @@ on: jobs: call-workflow: - uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@c970b0fb89bd966749280e832928db62040812bf From d3cb27325698682d0002bd52ffa30600b9e629d2 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Wed, 18 Oct 2023 12:40:50 -0400 Subject: [PATCH 28/85] [PM-4141] Bugfix - Non-Premium accounts can autofill TOTP codes with the autofill keyboard shortcut (#6496) * null totp seed from retrieved login cipher for autofill if the account does not have access to premium features * update tests --- .../services/autofill.service.spec.ts | 25 ++++++++++++++++--- .../src/autofill/services/autofill.service.ts | 5 ++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index f8f12fa7ddb..544b0da1a5c 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -519,8 +519,9 @@ describe("AutofillService", () => { it("returns a TOTP value", async () => { const totpCode = "123456"; autofillOptions.cipher.login.totp = "totp"; - jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValueOnce(false); - jest.spyOn(totpService, "getCode").mockReturnValueOnce(Promise.resolve(totpCode)); + jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(true); + jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValue(false); + jest.spyOn(totpService, "getCode").mockResolvedValue(totpCode); const autofillResult = await autofillService.doAutoFill(autofillOptions); @@ -529,6 +530,18 @@ describe("AutofillService", () => { expect(autofillResult).toBe(totpCode); }); + it("does not return a TOTP value if the user does not have premium features", async () => { + autofillOptions.cipher.login.totp = "totp"; + jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(false); + jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValue(false); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + expect(stateService.getDisableAutoTotpCopy).not.toHaveBeenCalled(); + expect(totpService.getCode).not.toHaveBeenCalled(); + expect(autofillResult).toBeNull(); + }); + it("returns a null value if the cipher type is not for a Login", async () => { autofillOptions.cipher.type = CipherType.Identity; autofillOptions.cipher.identity = mock(); @@ -563,11 +576,15 @@ describe("AutofillService", () => { it("returns a null value if the user has disabled `auto TOTP copy`", async () => { autofillOptions.cipher.login.totp = "totp"; autofillOptions.cipher.organizationUseTotp = true; - jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValueOnce(true); - jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValueOnce(true); + jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValue(true); + jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValue(true); + jest.spyOn(totpService, "getCode"); const autofillResult = await autofillService.doAutoFill(autofillOptions); + expect(stateService.getCanAccessPremium).toHaveBeenCalled(); + expect(stateService.getDisableAutoTotpCopy).toHaveBeenCalled(); + expect(totpService.getCode).not.toHaveBeenCalled(); expect(autofillResult).toBeNull(); }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index aca72562287..8ab20027756 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -153,6 +153,10 @@ export default class AutofillService implements AutofillServiceInterface { const canAccessPremium = await this.stateService.getCanAccessPremium(); const defaultUriMatch = (await this.stateService.getDefaultUriMatch()) ?? UriMatchType.Domain; + if (!canAccessPremium) { + options.cipher.login.totp = null; + } + let didAutofill = false; await Promise.all( options.pageDetails.map(async (pd) => { @@ -203,6 +207,7 @@ export default class AutofillService implements AutofillServiceInterface { { frameId: pd.frameId } ); + // Skip getting the TOTP code for clipboard in these cases if ( options.cipher.type !== CipherType.Login || totp !== null || From 0c782c137d3c6b4c64181d102a27d9e26bd6a690 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Wed, 18 Oct 2023 17:56:50 +0100 Subject: [PATCH 29/85] add artifact check for non prod deploy (#6624) * add artifact check for non prod deploy * UPDATE: add setup need for cfpages-deploy --- .github/workflows/deploy-non-prod-web.yml | 48 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-non-prod-web.yml b/.github/workflows/deploy-non-prod-web.yml index 47f0e0ff0a9..4145cffa1b2 100644 --- a/.github/workflows/deploy-non-prod-web.yml +++ b/.github/workflows/deploy-non-prod-web.yml @@ -41,11 +41,55 @@ jobs: echo "environment-branch=cf-pages-$ENV_NAME_LOWER" >> $GITHUB_OUTPUT echo "environment-artifact=web-*-cloud-${{ inputs.environment }}.zip" >> $GITHUB_OUTPUT + artifact-check: + name: Check if Web artifact is present + runs-on: ubuntu-22.04 + needs: setup + env: + _ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment-artifact }} + steps: + - name: Download latest cloud asset + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + id: download-artifacts + continue-on-error: true + with: + workflow: build-web.yml + path: apps/web + workflow_conclusion: success + branch: ${{ github.ref_name }} + artifacts: ${{ env._ENVIRONMENT_ARTIFACT }} + + - name: Login to Azure + if: ${{ steps.download-artifacts.outcome == 'failure' }} + uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve secrets for Build trigger + if: ${{ steps.download-artifacts.outcome == 'failure' }} + id: retrieve-secret + uses: bitwarden/gh-actions/get-keyvault-secrets@f096207b7a2f31723165aee6ad03e91716686e78 + with: + keyvault: "bitwarden-ci" + secrets: "github-pat-bitwarden-devops-bot-repo-scope" + + - name: Build server missing branch + if: ${{ steps.download-artifacts.outcome == 'failure' }} + uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5 + with: + owner: bitwarden + repo: clients + github_token: ${{ steps.retrieve-secret.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + workflow_file_name: build-web.yml + ref: ${{ github.ref_name }} + wait_interval: 100 cfpages-deploy: name: Deploy Web Vault to ${{ inputs.environment }} CloudFlare Pages branch - needs: setup - runs-on: ubuntu-20.04 + needs: + - setup + - artifact-check + runs-on: ubuntu-22.04 env: _ENVIRONMENT: ${{ needs.setup.outputs.environment }} _ENVIRONMENT_URL: ${{ needs.setup.outputs.environment-url }} From eabdbe3d1911c1e69388e67aa0fcdc3c94f6da67 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Wed, 18 Oct 2023 10:18:51 -0700 Subject: [PATCH 30/85] [AC-1595] Update SSO identifier hint (#6608) --- apps/web/src/locales/en/messages.json | 7 ++++--- .../bit-web/src/app/auth/sso/sso.component.html | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 390b7f71ad7..5b4b2ac8625 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3640,7 +3640,7 @@ }, "secretsAccessSuspended": { "message": "Suspended organizations cannot be accessed. Please contact your organization owner for assistance." - }, + }, "secretsCannotCreate": { "message": "Secrets cannot be created in suspended organizations. Please contact your organization owner for assistance." }, @@ -3995,8 +3995,9 @@ "ssoIdentifier": { "message": "SSO identifier" }, - "ssoIdentifierHint": { - "message": "Provide this ID to your members to login with SSO." + "ssoIdentifierHintPartOne": { + "message": "Provide this ID to your members to login with SSO. To bypass this step, set up ", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Provide this ID to your members to login with SSO. To bypass this step, set up Domain verification'" }, "unlinkSso": { "message": "Unlink SSO" diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index fa589b2438a..48567dcd505 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -30,7 +30,10 @@

{{ "singleSignOn" | i18n }}

{{ "ssoIdentifier" | i18n }} - {{ "ssoIdentifierHint" | i18n }} + + {{ "ssoIdentifierHintPartOne" | i18n }} +
{{ "domainVerification" | i18n }} +
From 21b1f87724e474249d9ca78f6efadc9fc9bcfb1f Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 18 Oct 2023 14:46:42 -0400 Subject: [PATCH 31/85] PM-4376 update size for passkey popup (#6615) --- .../src/platform/popup/browser-popout-window.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts index 2d661780ce0..c0ebcff6704 100644 --- a/apps/browser/src/platform/popup/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -122,8 +122,7 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { promptWindowPath, "fido2Popout", { - width: 200, - height: 500, + height: 450, } ); } From 5dd2e3a1e34be8c6c643aa537a4c7af95589f78e Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Wed, 18 Oct 2023 16:25:24 -0400 Subject: [PATCH 32/85] [PM-4358] Passkey can be Created but not Retrieved on eBay (#6617) * changed behaviour to require user verification when preferred use preferred when user verification is not defined * changed behaviour to require user verification when preferred use preferred when user verification is not defined --- .../vault/services/fido2/fido2-client.service.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts index 4c7eaf00a79..0d113d5d458 100644 --- a/libs/common/src/vault/services/fido2/fido2-client.service.ts +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -363,9 +363,14 @@ function mapToMakeCredentialParams({ (params.authenticatorSelection?.residentKey === undefined && params.authenticatorSelection?.requireResidentKey === true); + const requireUserVerification = + params.authenticatorSelection?.userVerification === "required" || + params.authenticatorSelection?.userVerification === "preferred" || + params.authenticatorSelection?.userVerification === undefined; + return { requireResidentKey, - requireUserVerification: params.authenticatorSelection?.userVerification === "required", + requireUserVerification, enterpriseAttestationPossible: params.attestation === "enterprise", excludeCredentialDescriptorList, credTypesAndPubKeyAlgs, @@ -398,9 +403,14 @@ function mapToGetAssertionParams({ type: "public-key", })); + const requireUserVerification = + params.userVerification === "required" || + params.userVerification === "preferred" || + params.userVerification === undefined; + return { rpId: params.rpId, - requireUserVerification: params.userVerification === "required", + requireUserVerification, hash: clientDataHash, allowCredentialDescriptorList, extensions: {}, From fbe960e76082c4ade6f955c31234d2f63afc202e Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 18 Oct 2023 16:44:39 -0400 Subject: [PATCH 33/85] PM-4382 minor cleanup around the passkey popup (#6629) --- .../src/platform/browser/browser-api.ts | 42 +++++++++---------- .../fido2/fido2-cipher-row.component.ts | 2 +- .../fido2/fido2-use-browser-link.component.ts | 4 +- .../components/fido2/fido2.component.html | 8 ++-- .../popup/components/fido2/fido2.component.ts | 4 +- .../components/vault/add-edit.component.ts | 15 ++++--- 6 files changed, 37 insertions(+), 38 deletions(-) diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index e8070f0d341..dce72f5cf65 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -235,6 +235,27 @@ export class BrowserApi { } } + static messageListener$() { + return new Observable((subscriber) => { + const handler = (message: unknown) => { + subscriber.next(message); + }; + + BrowserApi.messageListener("message", handler); + + return () => { + chrome.runtime.onMessage.removeListener(handler); + + if (BrowserApi.isSafariApi) { + const index = BrowserApi.registeredMessageListeners.indexOf(handler); + if (index !== -1) { + BrowserApi.registeredMessageListeners.splice(index, 1); + } + } + }; + }); + } + static storageChangeListener( callback: Parameters[0] ) { @@ -262,27 +283,6 @@ export class BrowserApi { }; } - static messageListener$() { - return new Observable((subscriber) => { - const handler = (message: unknown) => { - subscriber.next(message); - }; - - BrowserApi.messageListener("message", handler); - - return () => { - chrome.runtime.onMessage.removeListener(handler); - - if (BrowserApi.isSafariApi) { - const index = BrowserApi.registeredMessageListeners.indexOf(handler); - if (index !== -1) { - BrowserApi.registeredMessageListeners.splice(index, 1); - } - } - }; - }); - } - static sendMessage(subscriber: string, arg: any = {}) { const message = Object.assign({}, { command: subscriber }, arg); return chrome.runtime.sendMessage(message); diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts index 21ff136bf42..c07d2ef8860 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts @@ -14,7 +14,7 @@ export class Fido2CipherRowComponent { @Input() isSearching: boolean; @Input() isSelected: boolean; - selectCipher(c: CipherView) { + protected selectCipher(c: CipherView) { this.onSelected.emit(c); } } diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts index 712f728c320..aaebb225b87 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts @@ -11,9 +11,9 @@ import { templateUrl: "fido2-use-browser-link.component.html", }) export class Fido2UseBrowserLinkComponent { - fido2PopoutSessionData$ = fido2PopoutSessionData$(); + protected fido2PopoutSessionData$ = fido2PopoutSessionData$(); - async abort() { + protected async abort() { const sessionData = await firstValueFrom(this.fido2PopoutSessionData$); BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId, true); return; diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.html b/apps/browser/src/vault/popup/components/fido2/fido2.component.html index 0f298b67fb6..7233fa7636a 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.html +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.html @@ -7,13 +7,13 @@
- +
- + - +

{{ "passkeyAlreadyExists" | i18n }}

@@ -120,7 +120,7 @@
- +

{{ "noPasskeysFoundForThisApplication" | i18n }}

diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index ed0ddbd1443..03ae926859b 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -217,7 +217,7 @@ export class Fido2Component implements OnInit, OnDestroy { }); } - async submit() { + protected async submit() { const data = this.message$.value; if (data?.type === "PickCredentialRequest") { const userVerified = await this.handleUserVerification(data.userVerification, this.cipher); @@ -254,7 +254,7 @@ export class Fido2Component implements OnInit, OnDestroy { this.loading = true; } - async saveNewLogin() { + protected async saveNewLogin() { const data = this.message$.value; if (data?.type === "ConfirmNewCredentialRequest") { let userVerified = false; diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index ded14ec92ec..971f3bee5b8 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -168,13 +168,12 @@ export class AddEditComponent extends BaseAddEditComponent { async submit(): Promise { const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$); // Would be refactored after rework is done on the windows popout service + + const { isFido2Session, sessionId, userVerification } = fido2SessionData; if ( this.inPopout && - fido2SessionData.isFido2Session && - !(await this.handleFido2UserVerification( - fido2SessionData.sessionId, - fido2SessionData.userVerification - )) + isFido2Session && + !(await this.handleFido2UserVerification(sessionId, userVerification)) ) { return false; } @@ -184,11 +183,11 @@ export class AddEditComponent extends BaseAddEditComponent { return false; } - if (this.inPopout && fido2SessionData.isFido2Session) { + if (this.inPopout && isFido2Session) { BrowserFido2UserInterfaceSession.confirmNewCredentialResponse( - fido2SessionData.sessionId, + sessionId, this.cipher.id, - fido2SessionData.userVerification + userVerification ); return true; } From 742e6e3b950d124ee9b106e415ed7dc627679a60 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 18 Oct 2023 17:38:38 -0400 Subject: [PATCH 34/85] refactor search method in fido2 component to only show ciphers with matching uri when search text is empty (#6628) --- .../components/fido2/fido2.component.html | 2 +- .../popup/components/fido2/fido2.component.ts | 45 +++++-------------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.html b/apps/browser/src/vault/popup/components/fido2/fido2.component.html index 7233fa7636a..fa49192c725 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.html +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.html @@ -20,7 +20,7 @@ placeholder="{{ 'searchVault' | i18n }}" id="search" [(ngModel)]="searchText" - (input)="search(200)" + (input)="search()" autocomplete="off" appAutofocus /> diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 03ae926859b..b4ad5e5a528 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -319,46 +319,23 @@ export class Fido2Component implements OnInit, OnDestroy { }); } - async loadLoginCiphers() { - this.ciphers = (await this.cipherService.getAllDecrypted()).filter( - (cipher) => cipher.type === CipherType.Login && !cipher.isDeleted - ); - if (!this.hasLoadedAllCiphers) { - this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText); - } - await this.search(null); - } - - async search(timeout: number = null) { - this.searchPending = false; - if (this.searchTimeout != null) { - clearTimeout(this.searchTimeout); - } - - if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + protected async search() { + this.hasSearched = this.searchService.isSearchable(this.searchText); + this.searchPending = true; + if (this.hasSearched) { this.displayedCiphers = await this.searchService.searchCiphers( this.searchText, null, this.ciphers ); - return; + } else { + const equivalentDomains = this.settingsService.getEquivalentDomains(this.url); + this.displayedCiphers = this.ciphers.filter((cipher) => + cipher.login.matchesUri(this.url, equivalentDomains) + ); } - this.searchPending = true; - this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); - if (!this.hasLoadedAllCiphers && !this.hasSearched) { - await this.loadLoginCiphers(); - } else { - this.displayedCiphers = await this.searchService.searchCiphers( - this.searchText, - null, - this.ciphers - ); - } - this.searchPending = false; - this.selectedPasskey(this.displayedCiphers[0]); - }, timeout); + this.searchPending = false; + this.selectedPasskey(this.displayedCiphers[0]); } abort(fallback: boolean) { From d0e72f5554330cd30a048659f006c5378349a958 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 19 Oct 2023 10:03:32 +0200 Subject: [PATCH 35/85] [PM-4360] Move auth owned code into auth (#6595) --- .../{ => auth}/popup/components/set-pin.component.html | 0 .../src/{ => auth}/popup/components/set-pin.component.ts | 2 +- apps/browser/src/auth/popup/environment.component.ts | 2 +- apps/browser/src/auth/popup/register.component.ts | 2 +- apps/browser/src/auth/popup/set-password.component.ts | 2 +- apps/browser/src/background/main.background.ts | 4 ++-- apps/browser/src/popup/app.module.ts | 2 +- apps/browser/src/popup/services/services.module.ts | 2 +- apps/browser/src/popup/settings/settings.component.ts | 2 +- apps/desktop/src/app/accounts/settings.component.ts | 2 +- apps/desktop/src/app/app.module.ts | 2 +- .../src/{app => auth}/components/set-pin.component.html | 0 .../src/{app => auth}/components/set-pin.component.ts | 2 +- apps/desktop/src/auth/environment.component.ts | 2 +- apps/desktop/src/auth/register.component.ts | 2 +- apps/desktop/src/auth/set-password.component.ts | 2 +- .../app/auth/register-form/register-form.component.ts | 2 +- apps/web/src/app/auth/set-password.component.ts | 2 +- .../base-login-decryption-options.component.ts | 2 +- .../src/{ => auth}/components/environment.component.ts | 2 +- .../src/{ => auth}/components/register.component.ts | 9 +++++---- .../src/{ => auth}/components/set-password.component.ts | 2 +- .../src/{ => auth}/components/set-pin.component.ts | 2 +- libs/angular/src/services/jslib-services.module.ts | 4 ++-- .../device-trust-crypto.service.abstraction.ts | 2 +- .../auth/abstractions/devices-api.service.abstraction.ts | 2 +- .../abstractions/devices/devices.service.abstraction.ts | 0 .../abstractions/devices/responses/device.response.ts | 4 ++-- .../{ => auth}/abstractions/devices/views/device.view.ts | 4 ++-- .../device-trust-crypto.service.implementation.ts | 2 +- .../auth/services/device-trust-crypto.service.spec.ts | 2 +- .../auth/services/devices-api.service.implementation.ts | 5 +++-- .../services/devices/devices.service.implementation.ts | 4 ++-- .../devices/requests/trusted-device-keys.request.ts | 0 34 files changed, 41 insertions(+), 39 deletions(-) rename apps/browser/src/{ => auth}/popup/components/set-pin.component.html (100%) rename apps/browser/src/{ => auth}/popup/components/set-pin.component.ts (82%) rename apps/desktop/src/{app => auth}/components/set-pin.component.html (100%) rename apps/desktop/src/{app => auth}/components/set-pin.component.ts (82%) rename libs/angular/src/{ => auth}/components/environment.component.ts (97%) rename libs/angular/src/{ => auth}/components/register.component.ts (97%) rename libs/angular/src/{ => auth}/components/set-password.component.ts (99%) rename libs/angular/src/{ => auth}/components/set-pin.component.ts (97%) rename libs/common/src/{ => auth}/abstractions/devices/devices.service.abstraction.ts (100%) rename libs/common/src/{ => auth}/abstractions/devices/responses/device.response.ts (84%) rename libs/common/src/{ => auth}/abstractions/devices/views/device.view.ts (77%) rename libs/common/src/{ => auth}/services/devices/devices.service.implementation.ts (93%) rename libs/common/src/{ => auth}/services/devices/requests/trusted-device-keys.request.ts (100%) diff --git a/apps/browser/src/popup/components/set-pin.component.html b/apps/browser/src/auth/popup/components/set-pin.component.html similarity index 100% rename from apps/browser/src/popup/components/set-pin.component.html rename to apps/browser/src/auth/popup/components/set-pin.component.html diff --git a/apps/browser/src/popup/components/set-pin.component.ts b/apps/browser/src/auth/popup/components/set-pin.component.ts similarity index 82% rename from apps/browser/src/popup/components/set-pin.component.ts rename to apps/browser/src/auth/popup/components/set-pin.component.ts index 47b8fd72128..41ce33eabde 100644 --- a/apps/browser/src/popup/components/set-pin.component.ts +++ b/apps/browser/src/auth/popup/components/set-pin.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; -import { SetPinComponent as BaseSetPinComponent } from "@bitwarden/angular/components/set-pin.component"; +import { SetPinComponent as BaseSetPinComponent } from "@bitwarden/angular/auth/components/set-pin.component"; @Component({ templateUrl: "set-pin.component.html", diff --git a/apps/browser/src/auth/popup/environment.component.ts b/apps/browser/src/auth/popup/environment.component.ts index c70b5f597c1..a5cbfe7c34b 100644 --- a/apps/browser/src/auth/popup/environment.component.ts +++ b/apps/browser/src/auth/popup/environment.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { EnvironmentComponent as BaseEnvironmentComponent } from "@bitwarden/angular/components/environment.component"; +import { EnvironmentComponent as BaseEnvironmentComponent } from "@bitwarden/angular/auth/components/environment.component"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; diff --git a/apps/browser/src/auth/popup/register.component.ts b/apps/browser/src/auth/popup/register.component.ts index 1599af2216c..837fcb2160f 100644 --- a/apps/browser/src/auth/popup/register.component.ts +++ b/apps/browser/src/auth/popup/register.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; -import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/components/register.component"; +import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; diff --git a/apps/browser/src/auth/popup/set-password.component.ts b/apps/browser/src/auth/popup/set-password.component.ts index 73b092f32e0..565727f76e2 100644 --- a/apps/browser/src/auth/popup/set-password.component.ts +++ b/apps/browser/src/auth/popup/set-password.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/components/set-password.component"; +import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4fcfa685270..e00beaefe4a 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1,7 +1,6 @@ import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; -import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; @@ -18,6 +17,7 @@ import { ProviderService } from "@bitwarden/common/admin-console/services/provid import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; @@ -27,6 +27,7 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@ import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; +import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -63,7 +64,6 @@ import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/we import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; -import { DevicesServiceImplementation } from "@bitwarden/common/services/devices/devices.service.implementation"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index dd27a419a34..d452e2d512b 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -16,6 +16,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; +import { SetPinComponent } from "../auth/popup/components/set-pin.component"; import { EnvironmentComponent } from "../auth/popup/environment.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; @@ -60,7 +61,6 @@ import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; import { PopOutComponent } from "./components/pop-out.component"; import { PrivateModeWarningComponent } from "./components/private-mode-warning.component"; -import { SetPinComponent } from "./components/set-pin.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; import { AutofillComponent } from "./settings/autofill.component"; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 6770e152504..6eb0e075c16 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -7,7 +7,6 @@ import { ThemingService } from "@bitwarden/angular/services/theming/theming.serv import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -27,6 +26,7 @@ import { PolicyApiService } from "@bitwarden/common/admin-console/services/polic import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; diff --git a/apps/browser/src/popup/settings/settings.component.ts b/apps/browser/src/popup/settings/settings.component.ts index c2699ce498e..274ed871225 100644 --- a/apps/browser/src/popup/settings/settings.component.ts +++ b/apps/browser/src/popup/settings/settings.component.ts @@ -33,9 +33,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { DialogService } from "@bitwarden/components"; +import { SetPinComponent } from "../../auth/popup/components/set-pin.component"; import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { SetPinComponent } from "../components/set-pin.component"; import { PopupUtilsService } from "../services/popup-utils.service"; import { AboutComponent } from "./about.component"; diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index a2a9e71a32b..8d6ef07a515 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -19,9 +19,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService } from "@bitwarden/components"; +import { SetPinComponent } from "../../auth/components/set-pin.component"; import { flagEnabled } from "../../platform/flags"; import { ElectronStateService } from "../../platform/services/electron-state.service.abstraction"; -import { SetPinComponent } from "../components/set-pin.component"; @Component({ selector: "app-settings", templateUrl: "settings.component.html", diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 172447822c9..3436e98c998 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -10,6 +10,7 @@ import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password- import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; +import { SetPinComponent } from "../auth/components/set-pin.component"; import { DeleteAccountComponent } from "../auth/delete-account.component"; import { EnvironmentComponent } from "../auth/environment.component"; import { HintComponent } from "../auth/hint.component"; @@ -41,7 +42,6 @@ import { SettingsComponent } from "./accounts/settings.component"; import { VaultTimeoutInputComponent } from "./accounts/vault-timeout-input.component"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; -import { SetPinComponent } from "./components/set-pin.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { AccountSwitcherComponent } from "./layout/account-switcher.component"; import { HeaderComponent } from "./layout/header.component"; diff --git a/apps/desktop/src/app/components/set-pin.component.html b/apps/desktop/src/auth/components/set-pin.component.html similarity index 100% rename from apps/desktop/src/app/components/set-pin.component.html rename to apps/desktop/src/auth/components/set-pin.component.html diff --git a/apps/desktop/src/app/components/set-pin.component.ts b/apps/desktop/src/auth/components/set-pin.component.ts similarity index 82% rename from apps/desktop/src/app/components/set-pin.component.ts rename to apps/desktop/src/auth/components/set-pin.component.ts index 47b8fd72128..41ce33eabde 100644 --- a/apps/desktop/src/app/components/set-pin.component.ts +++ b/apps/desktop/src/auth/components/set-pin.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; -import { SetPinComponent as BaseSetPinComponent } from "@bitwarden/angular/components/set-pin.component"; +import { SetPinComponent as BaseSetPinComponent } from "@bitwarden/angular/auth/components/set-pin.component"; @Component({ templateUrl: "set-pin.component.html", diff --git a/apps/desktop/src/auth/environment.component.ts b/apps/desktop/src/auth/environment.component.ts index 1f01b7445b4..9c10a6679cb 100644 --- a/apps/desktop/src/auth/environment.component.ts +++ b/apps/desktop/src/auth/environment.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; -import { EnvironmentComponent as BaseEnvironmentComponent } from "@bitwarden/angular/components/environment.component"; +import { EnvironmentComponent as BaseEnvironmentComponent } from "@bitwarden/angular/auth/components/environment.component"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; diff --git a/apps/desktop/src/auth/register.component.ts b/apps/desktop/src/auth/register.component.ts index db38fd4721c..f02ce3dadaa 100644 --- a/apps/desktop/src/auth/register.component.ts +++ b/apps/desktop/src/auth/register.component.ts @@ -2,7 +2,7 @@ import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; -import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/components/register.component"; +import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; diff --git a/apps/desktop/src/auth/set-password.component.ts b/apps/desktop/src/auth/set-password.component.ts index fedd70b3133..c2c838c8e7f 100644 --- a/apps/desktop/src/auth/set-password.component.ts +++ b/apps/desktop/src/auth/set-password.component.ts @@ -1,7 +1,7 @@ import { Component, NgZone, OnDestroy } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/components/set-password.component"; +import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; diff --git a/apps/web/src/app/auth/register-form/register-form.component.ts b/apps/web/src/app/auth/register-form/register-form.component.ts index 0a494ec333e..a63a44e4af2 100644 --- a/apps/web/src/app/auth/register-form/register-form.component.ts +++ b/apps/web/src/app/auth/register-form/register-form.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; -import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/components/register.component"; +import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; diff --git a/apps/web/src/app/auth/set-password.component.ts b/apps/web/src/app/auth/set-password.component.ts index 49320ced5d8..1e799737e2a 100644 --- a/apps/web/src/app/auth/set-password.component.ts +++ b/apps/web/src/app/auth/set-password.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/components/set-password.component"; +import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options.component.ts index 3350c3999c6..0203e279197 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options.component.ts @@ -15,10 +15,10 @@ import { } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; diff --git a/libs/angular/src/components/environment.component.ts b/libs/angular/src/auth/components/environment.component.ts similarity index 97% rename from libs/angular/src/components/environment.component.ts rename to libs/angular/src/auth/components/environment.component.ts index 6260d34c1d1..d37c29ec3a2 100644 --- a/libs/angular/src/components/environment.component.ts +++ b/libs/angular/src/auth/components/environment.component.ts @@ -7,7 +7,7 @@ import { import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { ModalService } from "../services/modal.service"; +import { ModalService } from "../../services/modal.service"; @Directive() export class EnvironmentComponent { diff --git a/libs/angular/src/components/register.component.ts b/libs/angular/src/auth/components/register.component.ts similarity index 97% rename from libs/angular/src/components/register.component.ts rename to libs/angular/src/auth/components/register.component.ts index 5363be39419..0a930ac90b7 100644 --- a/libs/angular/src/components/register.component.ts +++ b/libs/angular/src/auth/components/register.component.ts @@ -21,13 +21,14 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { DialogService } from "@bitwarden/components"; -import { CaptchaProtectedComponent } from "../auth/components/captcha-protected.component"; import { AllValidationErrors, FormValidationErrorsService, -} from "../platform/abstractions/form-validation-errors.service"; -import { PasswordColorText } from "../shared/components/password-strength/password-strength.component"; -import { InputsFieldMatch } from "../validators/inputsFieldMatch.validator"; +} from "../../platform/abstractions/form-validation-errors.service"; +import { PasswordColorText } from "../../shared/components/password-strength/password-strength.component"; +import { InputsFieldMatch } from "../../validators/inputsFieldMatch.validator"; + +import { CaptchaProtectedComponent } from "./captcha-protected.component"; @Directive() export class RegisterComponent extends CaptchaProtectedComponent implements OnInit { diff --git a/libs/angular/src/components/set-password.component.ts b/libs/angular/src/auth/components/set-password.component.ts similarity index 99% rename from libs/angular/src/components/set-password.component.ts rename to libs/angular/src/auth/components/set-password.component.ts index 60230e7f313..7723b9f2e4c 100644 --- a/libs/angular/src/components/set-password.component.ts +++ b/libs/angular/src/auth/components/set-password.component.ts @@ -23,7 +23,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; -import { ChangePasswordComponent as BaseChangePasswordComponent } from "../auth/components/change-password.component"; +import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component"; @Directive() export class SetPasswordComponent extends BaseChangePasswordComponent { diff --git a/libs/angular/src/components/set-pin.component.ts b/libs/angular/src/auth/components/set-pin.component.ts similarity index 97% rename from libs/angular/src/components/set-pin.component.ts rename to libs/angular/src/auth/components/set-pin.component.ts index bf92417ae68..b17b7079acd 100644 --- a/libs/angular/src/components/set-pin.component.ts +++ b/libs/angular/src/auth/components/set-pin.component.ts @@ -6,7 +6,7 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { ModalRef } from "./modal/modal.ref"; +import { ModalRef } from "../../components/modal/modal.ref"; @Directive() export class SetPinComponent implements OnInit { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index e2019c7d33f..fbaf3411ec6 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -4,7 +4,6 @@ import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitward import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; -import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; @@ -43,6 +42,7 @@ import { import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; @@ -56,6 +56,7 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; +import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; @@ -100,7 +101,6 @@ import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-u import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service"; import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; -import { DevicesServiceImplementation } from "@bitwarden/common/services/devices/devices.service.implementation"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; diff --git a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts index c30a567681b..d63f15a706d 100644 --- a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts @@ -1,6 +1,6 @@ -import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { EncString } from "../../platform/models/domain/enc-string"; import { DeviceKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; export abstract class DeviceTrustCryptoServiceAbstraction { /** diff --git a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts index 1bf0385ba1d..c27da5c4cd3 100644 --- a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts @@ -1,5 +1,5 @@ -import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { ListResponse } from "../../models/response/list.response"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { SecretVerificationRequest } from "../models/request/secret-verification.request"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; diff --git a/libs/common/src/abstractions/devices/devices.service.abstraction.ts b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts similarity index 100% rename from libs/common/src/abstractions/devices/devices.service.abstraction.ts rename to libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts diff --git a/libs/common/src/abstractions/devices/responses/device.response.ts b/libs/common/src/auth/abstractions/devices/responses/device.response.ts similarity index 84% rename from libs/common/src/abstractions/devices/responses/device.response.ts rename to libs/common/src/auth/abstractions/devices/responses/device.response.ts index 46874027cda..a4e40037b05 100644 --- a/libs/common/src/abstractions/devices/responses/device.response.ts +++ b/libs/common/src/auth/abstractions/devices/responses/device.response.ts @@ -1,5 +1,5 @@ -import { DeviceType } from "../../../enums"; -import { BaseResponse } from "../../../models/response/base.response"; +import { DeviceType } from "../../../../enums"; +import { BaseResponse } from "../../../../models/response/base.response"; export class DeviceResponse extends BaseResponse { id: string; diff --git a/libs/common/src/abstractions/devices/views/device.view.ts b/libs/common/src/auth/abstractions/devices/views/device.view.ts similarity index 77% rename from libs/common/src/abstractions/devices/views/device.view.ts rename to libs/common/src/auth/abstractions/devices/views/device.view.ts index 5438aa4de1b..ce76c77a93b 100644 --- a/libs/common/src/abstractions/devices/views/device.view.ts +++ b/libs/common/src/auth/abstractions/devices/views/device.view.ts @@ -1,5 +1,5 @@ -import { DeviceType } from "../../../enums"; -import { View } from "../../../models/view/view"; +import { DeviceType } from "../../../../enums"; +import { View } from "../../../../models/view/view"; import { DeviceResponse } from "../responses/device.response"; export class DeviceView implements View { diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts index fe45c5e208e..956a232d4ae 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -1,4 +1,3 @@ -import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; @@ -14,6 +13,7 @@ import { } from "../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../types/csprng"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; import { SecretVerificationRequest } from "../models/request/secret-verification.request"; import { diff --git a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts index b56c8b922ab..66a14ac87ce 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts @@ -1,6 +1,5 @@ import { matches, mock } from "jest-mock-extended"; -import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { DeviceType } from "../../enums"; import { EncryptionType } from "../../enums/encryption-type.enum"; import { AppIdService } from "../../platform/abstractions/app-id.service"; @@ -17,6 +16,7 @@ import { UserKey, } from "../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../types/csprng"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; diff --git a/libs/common/src/auth/services/devices-api.service.implementation.ts b/libs/common/src/auth/services/devices-api.service.implementation.ts index e149a79ea2f..2a4331bc5c0 100644 --- a/libs/common/src/auth/services/devices-api.service.implementation.ts +++ b/libs/common/src/auth/services/devices-api.service.implementation.ts @@ -1,13 +1,14 @@ import { ApiService } from "../../abstractions/api.service"; -import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { ListResponse } from "../../models/response/list.response"; import { Utils } from "../../platform/misc/utils"; -import { TrustedDeviceKeysRequest } from "../../services/devices/requests/trusted-device-keys.request"; +import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; import { SecretVerificationRequest } from "../models/request/secret-verification.request"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; +import { TrustedDeviceKeysRequest } from "./devices/requests/trusted-device-keys.request"; + export class DevicesApiServiceImplementation implements DevicesApiServiceAbstraction { constructor(private apiService: ApiService) {} diff --git a/libs/common/src/services/devices/devices.service.implementation.ts b/libs/common/src/auth/services/devices/devices.service.implementation.ts similarity index 93% rename from libs/common/src/services/devices/devices.service.implementation.ts rename to libs/common/src/auth/services/devices/devices.service.implementation.ts index fe6e2a37d2a..c83a096df48 100644 --- a/libs/common/src/services/devices/devices.service.implementation.ts +++ b/libs/common/src/auth/services/devices/devices.service.implementation.ts @@ -1,10 +1,10 @@ import { Observable, defer, map } from "rxjs"; +import { ListResponse } from "../../../models/response/list.response"; import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction"; import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; import { DeviceView } from "../../abstractions/devices/views/device.view"; -import { DevicesApiServiceAbstraction } from "../../auth/abstractions/devices-api.service.abstraction"; -import { ListResponse } from "../../models/response/list.response"; +import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.service.abstraction"; /** * @class DevicesServiceImplementation diff --git a/libs/common/src/services/devices/requests/trusted-device-keys.request.ts b/libs/common/src/auth/services/devices/requests/trusted-device-keys.request.ts similarity index 100% rename from libs/common/src/services/devices/requests/trusted-device-keys.request.ts rename to libs/common/src/auth/services/devices/requests/trusted-device-keys.request.ts From 9e290a3fed3f7d247374138245259d7b34c4d0cd Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 19 Oct 2023 11:17:23 +0200 Subject: [PATCH 36/85] [PM-4222] Make importer UI reusable (#6504) * Split up import/export into separate modules * Fix routing and apply PR feedback * Renamed OrganizationExport exports to OrganizationVaultExport * Make import dialogs standalone and move them to libs/importer * Make import.component re-usable - Move functionality which was previously present on the org-import.component into import.component - Move import.component into libs/importer Make import.component standalone Create import-web.component to represent Web UI Fix module imports and routing Remove unused org-import-files * Renamed filenames according to export rename * Make ImportWebComponent standalone, simplify routing * Pass organizationId as Input to ImportComponent * use formLoading and formDisabled outputs * Emit an event when the import succeeds Remove Angular router from base-component as other clients might not have routing (i.e. desktop) Move logic that happened on web successful import into the import-web.component * fix table themes on desktop & browser * fix fileSelector button styles * update selectors to use tools prefix; remove unused selectors * Wall off UI components in libs/importer Create barrel-file for libs/importer/components Remove components and dialog exports from libs/importer/index.ts Extend libs/shared/tsconfig.libs.json to include @bitwarden/importer/ui -> libs/importer/components Extend apps/web/tsconfig.ts to include @bitwarden/importer/ui Update all usages * Rename @bitwarden/importer to @bitwarden/importer/core Create more barrel files in libs/importer/* Update imports within libs/importer Extend tsconfig files Update imports in web, desktop, browser and cli * Lazy-load the ImportWebComponent via both routes * Use SharedModule as import in import-web.component * File selector should be displayed as secondary * Use bitSubmit to override submit preventDefault (#6607) Co-authored-by: Daniel James Smith --------- Co-authored-by: Daniel James Smith Co-authored-by: William Martin --- .../browser/src/background/main.background.ts | 2 +- .../src/popup/services/services.module.ts | 2 +- .../import-api-service.factory.ts | 2 +- .../import-service.factory.ts | 2 +- apps/browser/tsconfig.json | 3 +- apps/cli/src/bw.ts | 2 +- apps/cli/src/tools/import.command.ts | 2 +- apps/cli/tsconfig.json | 2 +- apps/desktop/tsconfig.json | 3 +- .../organization-settings-routing.module.ts | 11 +- .../tools/import/org-import-routing.module.ts | 25 ---- .../tools/import/org-import.component.ts | 99 ------------- .../tools/import/org-import.module.ts | 44 ------ apps/web/src/app/oss-routing.module.ts | 6 +- .../dialog/file-password-prompt.component.ts | 20 --- .../app/tools/import/import-routing.module.ts | 17 --- .../tools/import/import-web.component.html | 18 +++ .../app/tools/import/import-web.component.ts | 51 +++++++ .../web/src/app/tools/import/import.module.ts | 54 ------- apps/web/src/scss/pages.scss | 2 +- apps/web/tsconfig.json | 3 +- .../src/services/jslib-services.module.ts | 2 +- libs/components/src/async-actions/index.ts | 1 + libs/components/src/table/table.component.css | 4 + libs/components/src/table/table.component.ts | 2 + libs/components/src/tw-theme.css | 1 + .../file-password-prompt.component.html | 4 +- .../dialog/file-password-prompt.component.ts | 43 ++++++ .../dialog/import-error-dialog.component.html | 0 .../dialog/import-error-dialog.component.ts | 7 +- .../import-success-dialog.component.html | 0 .../dialog/import-success-dialog.component.ts | 9 +- .../importer/src/components}/dialog/index.ts | 0 .../src/components}/import.component.html | 20 +-- .../src/components}/import.component.ts | 135 +++++++++++++++--- libs/importer/src/components/index.ts | 3 + libs/importer/src/index.ts | 10 +- libs/importer/src/models/index.ts | 2 + libs/importer/src/services/index.ts | 5 + libs/shared/tsconfig.libs.json | 3 +- tsconfig.eslint.json | 3 +- tsconfig.json | 3 +- 42 files changed, 299 insertions(+), 328 deletions(-) delete mode 100644 apps/web/src/app/admin-console/organizations/tools/import/org-import-routing.module.ts delete mode 100644 apps/web/src/app/admin-console/organizations/tools/import/org-import.component.ts delete mode 100644 apps/web/src/app/admin-console/organizations/tools/import/org-import.module.ts delete mode 100644 apps/web/src/app/tools/import/dialog/file-password-prompt.component.ts delete mode 100644 apps/web/src/app/tools/import/import-routing.module.ts create mode 100644 apps/web/src/app/tools/import/import-web.component.html create mode 100644 apps/web/src/app/tools/import/import-web.component.ts delete mode 100644 apps/web/src/app/tools/import/import.module.ts create mode 100644 libs/components/src/table/table.component.css rename {apps/web/src/app/tools/import => libs/importer/src/components}/dialog/file-password-prompt.component.html (90%) create mode 100644 libs/importer/src/components/dialog/file-password-prompt.component.ts rename {apps/web/src/app/tools/import => libs/importer/src/components}/dialog/import-error-dialog.component.html (100%) rename {apps/web/src/app/tools/import => libs/importer/src/components}/dialog/import-error-dialog.component.ts (72%) rename {apps/web/src/app/tools/import => libs/importer/src/components}/dialog/import-success-dialog.component.html (100%) rename {apps/web/src/app/tools/import => libs/importer/src/components}/dialog/import-success-dialog.component.ts (84%) rename {apps/web/src/app/tools/import => libs/importer/src/components}/dialog/index.ts (100%) rename {apps/web/src/app/tools/import => libs/importer/src/components}/import.component.html (97%) rename {apps/web/src/app/tools/import => libs/importer/src/components}/import.component.ts (78%) create mode 100644 libs/importer/src/components/index.ts create mode 100644 libs/importer/src/models/index.ts create mode 100644 libs/importer/src/services/index.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index e00beaefe4a..9d4cba04e7c 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -113,7 +113,7 @@ import { ImportApiService, ImportServiceAbstraction, ImportService, -} from "@bitwarden/importer"; +} from "@bitwarden/importer/core"; import { BrowserOrganizationService } from "../admin-console/services/browser-organization.service"; import { BrowserPolicyService } from "../admin-console/services/browser-policy.service"; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 6eb0e075c16..2622b8ef13b 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -84,7 +84,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/exporter/vault-export"; -import { ImportServiceAbstraction } from "@bitwarden/importer"; +import { ImportServiceAbstraction } from "@bitwarden/importer/core"; import { BrowserOrganizationService } from "../../admin-console/services/browser-organization.service"; import { BrowserPolicyService } from "../../admin-console/services/browser-policy.service"; diff --git a/apps/browser/src/tools/background/service_factories/import-api-service.factory.ts b/apps/browser/src/tools/background/service_factories/import-api-service.factory.ts index 4344647bbe8..00954a0dc60 100644 --- a/apps/browser/src/tools/background/service_factories/import-api-service.factory.ts +++ b/apps/browser/src/tools/background/service_factories/import-api-service.factory.ts @@ -1,4 +1,4 @@ -import { ImportApiService, ImportApiServiceAbstraction } from "@bitwarden/importer"; +import { ImportApiService, ImportApiServiceAbstraction } from "@bitwarden/importer/core"; import { ApiServiceInitOptions, diff --git a/apps/browser/src/tools/background/service_factories/import-service.factory.ts b/apps/browser/src/tools/background/service_factories/import-service.factory.ts index 3dc9bbd4f0a..7f5328f4d07 100644 --- a/apps/browser/src/tools/background/service_factories/import-service.factory.ts +++ b/apps/browser/src/tools/background/service_factories/import-service.factory.ts @@ -1,4 +1,4 @@ -import { ImportService, ImportServiceAbstraction } from "@bitwarden/importer"; +import { ImportService, ImportServiceAbstraction } from "@bitwarden/importer/core"; import { cryptoServiceFactory, diff --git a/apps/browser/tsconfig.json b/apps/browser/tsconfig.json index 357be6c5281..3ad2be7c02c 100644 --- a/apps/browser/tsconfig.json +++ b/apps/browser/tsconfig.json @@ -15,7 +15,8 @@ "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], "@bitwarden/exporter/*": ["../../libs/exporter/src/*"], - "@bitwarden/importer": ["../../libs/importer/src"], + "@bitwarden/importer/core": ["../../libs/importer/src"], + "@bitwarden/importer/ui": ["../../libs/importer/src/components"], "@bitwarden/vault": ["../../libs/vault/src"] }, "useDefineForClassFields": false diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index ca501690193..ffaec215e26 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -74,7 +74,7 @@ import { ImportApiServiceAbstraction, ImportService, ImportServiceAbstraction, -} from "@bitwarden/importer"; +} from "@bitwarden/importer/core"; import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-function.service"; import { CliConfigService } from "./platform/services/cli-config.service"; diff --git a/apps/cli/src/tools/import.command.ts b/apps/cli/src/tools/import.command.ts index e3f24b960f8..1be562239f5 100644 --- a/apps/cli/src/tools/import.command.ts +++ b/apps/cli/src/tools/import.command.ts @@ -3,7 +3,7 @@ import * as inquirer from "inquirer"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { ImportServiceAbstraction, ImportType } from "@bitwarden/importer"; +import { ImportServiceAbstraction, ImportType } from "@bitwarden/importer/core"; import { Response } from "../models/response"; import { MessageResponse } from "../models/response/message.response"; diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 420496ad93c..395d91564a0 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -14,7 +14,7 @@ "paths": { "@bitwarden/common/spec": ["../../libs/common/spec"], "@bitwarden/common/*": ["../../libs/common/src/*"], - "@bitwarden/importer": ["../../libs/importer/src"], + "@bitwarden/importer/core": ["../../libs/importer/src"], "@bitwarden/exporter/*": ["../../libs/exporter/src/*"], "@bitwarden/node/*": ["../../libs/node/src/*"] } diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 67bfba64426..b479905f897 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -15,7 +15,8 @@ "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], "@bitwarden/exporter/*": ["../../libs/exporter/src/*"], - "@bitwarden/importer": ["../../libs/importer/src"], + "@bitwarden/importer/core": ["../../libs/importer/src"], + "@bitwarden/importer/ui": ["../../libs/importer/src/components"], "@bitwarden/vault": ["../../libs/vault/src"] }, "useDefineForClassFields": false diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index 1606d86497c..d853b942cec 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -48,8 +48,15 @@ const routes: Routes = [ children: [ { path: "import", - loadChildren: () => - import("../tools/import/org-import.module").then((m) => m.OrganizationImportModule), + loadComponent: () => + import("../../../tools/import/import-web.component").then( + (mod) => mod.ImportWebComponent + ), + canActivate: [OrganizationPermissionsGuard], + data: { + titleId: "importData", + organizationPermissions: (org: Organization) => org.canAccessImportExport, + }, }, { path: "export", diff --git a/apps/web/src/app/admin-console/organizations/tools/import/org-import-routing.module.ts b/apps/web/src/app/admin-console/organizations/tools/import/org-import-routing.module.ts deleted file mode 100644 index 9b4b4b5c787..00000000000 --- a/apps/web/src/app/admin-console/organizations/tools/import/org-import-routing.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NgModule } from "@angular/core"; -import { RouterModule, Routes } from "@angular/router"; - -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - -import { OrganizationPermissionsGuard } from "../../guards/org-permissions.guard"; - -import { OrganizationImportComponent } from "./org-import.component"; - -const routes: Routes = [ - { - path: "", - component: OrganizationImportComponent, - canActivate: [OrganizationPermissionsGuard], - data: { - titleId: "importData", - organizationPermissions: (org: Organization) => org.canAccessImportExport, - }, - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], -}) -export class OrganizationImportRoutingModule {} diff --git a/apps/web/src/app/admin-console/organizations/tools/import/org-import.component.ts b/apps/web/src/app/admin-console/organizations/tools/import/org-import.component.ts deleted file mode 100644 index b514f2bcf38..00000000000 --- a/apps/web/src/app/admin-console/organizations/tools/import/org-import.component.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Component } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { switchMap, takeUntil } from "rxjs/operators"; - -import { - canAccessVaultTab, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -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 { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService } from "@bitwarden/components"; -import { ImportServiceAbstraction } from "@bitwarden/importer"; - -import { ImportComponent } from "../../../../tools/import/import.component"; - -@Component({ - selector: "app-org-import", - templateUrl: "../../../../tools/import/import.component.html", -}) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class OrganizationImportComponent extends ImportComponent { - organization: Organization; - - protected get importBlockedByPolicy(): boolean { - return false; - } - - constructor( - i18nService: I18nService, - importService: ImportServiceAbstraction, - router: Router, - private route: ActivatedRoute, - platformUtilsService: PlatformUtilsService, - policyService: PolicyService, - organizationService: OrganizationService, - logService: LogService, - syncService: SyncService, - dialogService: DialogService, - folderService: FolderService, - collectionService: CollectionService, - formBuilder: FormBuilder - ) { - super( - i18nService, - importService, - router, - platformUtilsService, - policyService, - logService, - syncService, - dialogService, - folderService, - collectionService, - organizationService, - formBuilder - ); - } - - ngOnInit() { - this.route.params - .pipe( - switchMap((params) => this.organizationService.get$(params.organizationId)), - takeUntil(this.destroy$) - ) - .subscribe((organization) => { - this.organizationId = organization.id; - this.organization = organization; - }); - super.ngOnInit(); - } - - protected async onSuccessfulImport(): Promise { - if (canAccessVaultTab(this.organization)) { - await this.router.navigate(["organizations", this.organizationId, "vault"]); - } else { - this.fileSelected = null; - } - } - - protected async performImport() { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "warning" }, - content: { key: "importWarning", placeholders: [this.organization.name] }, - type: "warning", - }); - - if (!confirmed) { - return; - } - await super.performImport(); - } -} diff --git a/apps/web/src/app/admin-console/organizations/tools/import/org-import.module.ts b/apps/web/src/app/admin-console/organizations/tools/import/org-import.module.ts deleted file mode 100644 index 66a22158e20..00000000000 --- a/apps/web/src/app/admin-console/organizations/tools/import/org-import.module.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { - ImportService, - ImportServiceAbstraction, - ImportApiService, - ImportApiServiceAbstraction, -} from "@bitwarden/importer"; - -import { LooseComponentsModule, SharedModule } from "../../../../shared"; - -import { OrganizationImportRoutingModule } from "./org-import-routing.module"; -import { OrganizationImportComponent } from "./org-import.component"; - -@NgModule({ - imports: [SharedModule, LooseComponentsModule, OrganizationImportRoutingModule], - declarations: [OrganizationImportComponent], - providers: [ - { - provide: ImportApiServiceAbstraction, - useClass: ImportApiService, - deps: [ApiService], - }, - { - provide: ImportServiceAbstraction, - useClass: ImportService, - deps: [ - CipherService, - FolderService, - ImportApiServiceAbstraction, - I18nService, - CollectionService, - CryptoService, - ], - }, - ], -}) -export class OrganizationImportModule {} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index d8a57cd3fb3..eee22c0d467 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -255,7 +255,11 @@ const routes: Routes = [ { path: "", pathMatch: "full", redirectTo: "generator" }, { path: "import", - loadChildren: () => import("./tools/import/import.module").then((m) => m.ImportModule), + loadComponent: () => + import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent), + data: { + titleId: "importData", + }, }, { path: "export", diff --git a/apps/web/src/app/tools/import/dialog/file-password-prompt.component.ts b/apps/web/src/app/tools/import/dialog/file-password-prompt.component.ts deleted file mode 100644 index d0cbfe20fd6..00000000000 --- a/apps/web/src/app/tools/import/dialog/file-password-prompt.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DialogRef } from "@angular/cdk/dialog"; -import { Component } from "@angular/core"; -import { FormControl, Validators } from "@angular/forms"; - -@Component({ - templateUrl: "file-password-prompt.component.html", -}) -export class FilePasswordPromptComponent { - filePassword = new FormControl("", Validators.required); - - constructor(public dialogRef: DialogRef) {} - - submit() { - this.filePassword.markAsTouched(); - if (!this.filePassword.valid) { - return; - } - this.dialogRef.close(this.filePassword.value); - } -} diff --git a/apps/web/src/app/tools/import/import-routing.module.ts b/apps/web/src/app/tools/import/import-routing.module.ts deleted file mode 100644 index 82d0a733846..00000000000 --- a/apps/web/src/app/tools/import/import-routing.module.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NgModule } from "@angular/core"; -import { RouterModule, Routes } from "@angular/router"; - -import { ImportComponent } from "./import.component"; - -const routes: Routes = [ - { - path: "", - component: ImportComponent, - data: { titleId: "importData" }, - }, -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], -}) -export class ImportRoutingModule {} diff --git a/apps/web/src/app/tools/import/import-web.component.html b/apps/web/src/app/tools/import/import-web.component.html new file mode 100644 index 00000000000..1d655db3243 --- /dev/null +++ b/apps/web/src/app/tools/import/import-web.component.html @@ -0,0 +1,18 @@ +

{{ "importData" | i18n }}

+ + diff --git a/apps/web/src/app/tools/import/import-web.component.ts b/apps/web/src/app/tools/import/import-web.component.ts new file mode 100644 index 00000000000..952a717cd95 --- /dev/null +++ b/apps/web/src/app/tools/import/import-web.component.ts @@ -0,0 +1,51 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { + OrganizationService, + canAccessVaultTab, +} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ImportComponent } from "@bitwarden/importer/ui"; + +import { SharedModule } from "../../shared"; + +@Component({ + templateUrl: "import-web.component.html", + standalone: true, + imports: [SharedModule, ImportComponent], +}) +export class ImportWebComponent implements OnInit { + protected routeOrgId: string = null; + protected loading = false; + protected disabled = false; + + constructor( + private route: ActivatedRoute, + private organizationService: OrganizationService, + private router: Router + ) {} + + ngOnInit(): void { + this.routeOrgId = this.route.snapshot.paramMap.get("organizationId"); + } + + /** + * Callback that is called after a successful import. + */ + protected async onSuccessfulImport(organizationId: string): Promise { + if (!organizationId) { + await this.router.navigate(["vault"]); + return; + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (organization == null) { + return; + } + + if (canAccessVaultTab(organization)) { + await this.router.navigate(["organizations", organizationId, "vault"]); + } + } +} diff --git a/apps/web/src/app/tools/import/import.module.ts b/apps/web/src/app/tools/import/import.module.ts deleted file mode 100644 index 34f71cfab7f..00000000000 --- a/apps/web/src/app/tools/import/import.module.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { - ImportService, - ImportServiceAbstraction, - ImportApiService, - ImportApiServiceAbstraction, -} from "@bitwarden/importer"; - -import { LooseComponentsModule, SharedModule } from "../../shared"; - -import { - ImportErrorDialogComponent, - ImportSuccessDialogComponent, - FilePasswordPromptComponent, -} from "./dialog"; -import { ImportRoutingModule } from "./import-routing.module"; -import { ImportComponent } from "./import.component"; - -@NgModule({ - imports: [SharedModule, LooseComponentsModule, ImportRoutingModule], - declarations: [ - ImportComponent, - FilePasswordPromptComponent, - ImportErrorDialogComponent, - ImportSuccessDialogComponent, - ], - providers: [ - { - provide: ImportApiServiceAbstraction, - useClass: ImportApiService, - deps: [ApiService], - }, - { - provide: ImportServiceAbstraction, - useClass: ImportService, - deps: [ - CipherService, - FolderService, - ImportApiServiceAbstraction, - I18nService, - CollectionService, - CryptoService, - ], - }, - ], -}) -export class ImportModule {} diff --git a/apps/web/src/scss/pages.scss b/apps/web/src/scss/pages.scss index 4dbee2ac507..684d45a1a66 100644 --- a/apps/web/src/scss/pages.scss +++ b/apps/web/src/scss/pages.scss @@ -32,7 +32,7 @@ app-password-generator-history { } } -app-import { +tools-import { textarea { height: 150px; } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 67d24511584..a1ea71f4bb9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,7 +10,8 @@ "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], "@bitwarden/exporter/*": ["../../libs/exporter/src/*"], - "@bitwarden/importer": ["../../libs/importer/src"], + "@bitwarden/importer/core": ["../../libs/importer/src"], + "@bitwarden/importer/ui": ["../../libs/importer/src/components"], "@bitwarden/vault": ["../../libs/vault/src"], "@bitwarden/web-vault/*": ["src/*"] } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index fbaf3411ec6..e52e9c394e4 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -154,7 +154,7 @@ import { ImportApiServiceAbstraction, ImportService, ImportServiceAbstraction, -} from "@bitwarden/importer"; +} from "@bitwarden/importer/core"; import { PasswordRepromptService } from "@bitwarden/vault"; import { AuthGuard } from "../auth/guards/auth.guard"; diff --git a/libs/components/src/async-actions/index.ts b/libs/components/src/async-actions/index.ts index 6515ffc47ca..05f49902a78 100644 --- a/libs/components/src/async-actions/index.ts +++ b/libs/components/src/async-actions/index.ts @@ -1,3 +1,4 @@ export * from "./async-actions.module"; export * from "./bit-action.directive"; export * from "./form-button.directive"; +export * from "./bit-submit.directive"; diff --git a/libs/components/src/table/table.component.css b/libs/components/src/table/table.component.css new file mode 100644 index 00000000000..e764a9130ea --- /dev/null +++ b/libs/components/src/table/table.component.css @@ -0,0 +1,4 @@ +th { + text-align: inherit; + text-align: -webkit-match-parent; +} diff --git a/libs/components/src/table/table.component.ts b/libs/components/src/table/table.component.ts index 9f36d0a70fc..b4d6d1931d1 100644 --- a/libs/components/src/table/table.component.ts +++ b/libs/components/src/table/table.component.ts @@ -39,6 +39,8 @@ export class TableComponent implements OnDestroy, AfterContentChecked { "tw-w-full", "tw-leading-normal", "tw-text-main", + "tw-border-collapse", + "tw-text-start", this.layout === "auto" ? "tw-table-auto" : "tw-table-fixed", ]; } diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index 1ff2064fdbd..14b5fcb4338 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -159,6 +159,7 @@ } @import "./search/search.component.css"; +@import "./table/table.component.css"; /** * tw-break-words does not work with table cells: diff --git a/apps/web/src/app/tools/import/dialog/file-password-prompt.component.html b/libs/importer/src/components/dialog/file-password-prompt.component.html similarity index 90% rename from apps/web/src/app/tools/import/dialog/file-password-prompt.component.html rename to libs/importer/src/components/dialog/file-password-prompt.component.html index 6c849da69df..823d6ebec1b 100644 --- a/apps/web/src/app/tools/import/dialog/file-password-prompt.component.html +++ b/libs/importer/src/components/dialog/file-password-prompt.component.html @@ -1,4 +1,4 @@ -
+ {{ "confirmVaultImport" | i18n }} @@ -12,7 +12,7 @@ bitInput type="password" name="filePassword" - [formControl]="filePassword" + formControlName="filePassword" appAutofocus appInputVerbatim /> diff --git a/libs/importer/src/components/dialog/file-password-prompt.component.ts b/libs/importer/src/components/dialog/file-password-prompt.component.ts new file mode 100644 index 00000000000..864fdafab08 --- /dev/null +++ b/libs/importer/src/components/dialog/file-password-prompt.component.ts @@ -0,0 +1,43 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +@Component({ + templateUrl: "file-password-prompt.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + DialogModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + ReactiveFormsModule, + ], +}) +export class FilePasswordPromptComponent { + formGroup = this.formBuilder.group({ + filePassword: ["", Validators.required], + }); + + constructor(public dialogRef: DialogRef, protected formBuilder: FormBuilder) {} + + submit = () => { + this.formGroup.markAsTouched(); + if (!this.formGroup.valid) { + return; + } + this.dialogRef.close(this.formGroup.value.filePassword); + }; +} diff --git a/apps/web/src/app/tools/import/dialog/import-error-dialog.component.html b/libs/importer/src/components/dialog/import-error-dialog.component.html similarity index 100% rename from apps/web/src/app/tools/import/dialog/import-error-dialog.component.html rename to libs/importer/src/components/dialog/import-error-dialog.component.html diff --git a/apps/web/src/app/tools/import/dialog/import-error-dialog.component.ts b/libs/importer/src/components/dialog/import-error-dialog.component.ts similarity index 72% rename from apps/web/src/app/tools/import/dialog/import-error-dialog.component.ts rename to libs/importer/src/components/dialog/import-error-dialog.component.ts index abb68cf53b1..4d766e3619d 100644 --- a/apps/web/src/app/tools/import/dialog/import-error-dialog.component.ts +++ b/libs/importer/src/components/dialog/import-error-dialog.component.ts @@ -1,7 +1,9 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; import { Component, Inject, OnInit } from "@angular/core"; -import { TableDataSource } from "@bitwarden/components"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, DialogModule, TableDataSource, TableModule } from "@bitwarden/components"; export interface ErrorListItem { type: string; @@ -9,8 +11,9 @@ export interface ErrorListItem { } @Component({ - selector: "app-import-error-dialog", templateUrl: "./import-error-dialog.component.html", + standalone: true, + imports: [CommonModule, JslibModule, DialogModule, TableModule, ButtonModule], }) export class ImportErrorDialogComponent implements OnInit { protected dataSource = new TableDataSource(); diff --git a/apps/web/src/app/tools/import/dialog/import-success-dialog.component.html b/libs/importer/src/components/dialog/import-success-dialog.component.html similarity index 100% rename from apps/web/src/app/tools/import/dialog/import-success-dialog.component.html rename to libs/importer/src/components/dialog/import-success-dialog.component.html diff --git a/apps/web/src/app/tools/import/dialog/import-success-dialog.component.ts b/libs/importer/src/components/dialog/import-success-dialog.component.ts similarity index 84% rename from apps/web/src/app/tools/import/dialog/import-success-dialog.component.ts rename to libs/importer/src/components/dialog/import-success-dialog.component.ts index 215784cb6f4..4d10002da48 100644 --- a/apps/web/src/app/tools/import/dialog/import-success-dialog.component.ts +++ b/libs/importer/src/components/dialog/import-success-dialog.component.ts @@ -1,9 +1,12 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; import { Component, Inject, OnInit } from "@angular/core"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; -import { TableDataSource } from "@bitwarden/components"; -import { ImportResult } from "@bitwarden/importer"; +import { ButtonModule, DialogModule, TableDataSource, TableModule } from "@bitwarden/components"; + +import { ImportResult } from "../../models"; export interface ResultList { icon: string; @@ -13,6 +16,8 @@ export interface ResultList { @Component({ templateUrl: "./import-success-dialog.component.html", + standalone: true, + imports: [CommonModule, JslibModule, DialogModule, TableModule, ButtonModule], }) export class ImportSuccessDialogComponent implements OnInit { protected dataSource = new TableDataSource(); diff --git a/apps/web/src/app/tools/import/dialog/index.ts b/libs/importer/src/components/dialog/index.ts similarity index 100% rename from apps/web/src/app/tools/import/dialog/index.ts rename to libs/importer/src/components/dialog/index.ts diff --git a/apps/web/src/app/tools/import/import.component.html b/libs/importer/src/components/import.component.html similarity index 97% rename from apps/web/src/app/tools/import/import.component.html rename to libs/importer/src/components/import.component.html index c9e3285c291..83e119fcc57 100644 --- a/apps/web/src/app/tools/import/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -1,9 +1,7 @@ -

{{ "importData" | i18n }}

- {{ "personalOwnershipPolicyInEffectImports" | i18n }} - + {{ "importDestination" | i18n }} @@ -349,12 +347,7 @@

{{ "importData" | i18n }}

{{ "selectImportFile" | i18n }}
- {{ this.fileSelected ? this.fileSelected.name : ("noFileChosen" | i18n) }} @@ -380,13 +373,4 @@

{{ "importData" | i18n }}

formControlName="fileContents" > - diff --git a/apps/web/src/app/tools/import/import.component.ts b/libs/importer/src/components/import.component.ts similarity index 78% rename from apps/web/src/app/tools/import/import.component.ts rename to libs/importer/src/components/import.component.ts index 1f71b2fd775..3a111720ab3 100644 --- a/apps/web/src/app/tools/import/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -1,10 +1,20 @@ -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { FormBuilder, Validators } from "@angular/forms"; -import { Router } from "@angular/router"; +import { CommonModule } from "@angular/common"; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import * as JSZip from "jszip"; import { concat, Observable, Subject, lastValueFrom, combineLatest } from "rxjs"; import { map, takeUntil } from "rxjs/operators"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { canAccessImportExport, OrganizationService, @@ -12,22 +22,35 @@ import { import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { DialogService } from "@bitwarden/components"; import { - ImportOption, - ImportResult, + AsyncActionsModule, + BitSubmitDirective, + ButtonModule, + CalloutModule, + DialogService, + FormFieldModule, + IconButtonModule, + SelectModule, +} from "@bitwarden/components"; + +import { ImportOption, ImportResult, ImportType } from "../models"; +import { + ImportApiService, + ImportApiServiceAbstraction, + ImportService, ImportServiceAbstraction, - ImportType, -} from "@bitwarden/importer"; +} from "../services"; import { FilePasswordPromptComponent, @@ -36,8 +59,39 @@ import { } from "./dialog"; @Component({ - selector: "app-import", + selector: "tools-import", templateUrl: "import.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + SelectModule, + CalloutModule, + ReactiveFormsModule, + ], + providers: [ + { + provide: ImportApiServiceAbstraction, + useClass: ImportApiService, + deps: [ApiService], + }, + { + provide: ImportServiceAbstraction, + useClass: ImportService, + deps: [ + CipherService, + FolderService, + ImportApiServiceAbstraction, + I18nService, + CollectionService, + CryptoService, + ], + }, + ], }) export class ImportComponent implements OnInit, OnDestroy { featuredImportOptions: ImportOption[]; @@ -49,7 +103,24 @@ export class ImportComponent implements OnInit, OnDestroy { collections$: Observable; organizations$: Observable; - protected organizationId: string = null; + private _organizationId: string; + + get organizationId(): string { + return this._organizationId; + } + + @Input() set organizationId(value: string) { + this._organizationId = value; + this.organizationService + .get$(this._organizationId) + .pipe(takeUntil(this.destroy$)) + .subscribe((organization) => { + this._organizationId = organization?.id; + this.organization = organization; + }); + } + + protected organization: Organization; protected destroy$ = new Subject(); private _importBlockedByPolicy = false; @@ -68,10 +139,31 @@ export class ImportComponent implements OnInit, OnDestroy { file: [], }); + @ViewChild(BitSubmitDirective) + private bitSubmit: BitSubmitDirective; + + @Output() + formLoading = new EventEmitter(); + + @Output() + formDisabled = new EventEmitter(); + + @Output() + onSuccessfulImport = new EventEmitter(); + + ngAfterViewInit(): void { + this.bitSubmit.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => { + this.formLoading.emit(loading); + }); + + this.bitSubmit.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => { + this.formDisabled.emit(disabled); + }); + } + constructor( protected i18nService: I18nService, protected importService: ImportServiceAbstraction, - protected router: Router, protected platformUtilsService: PlatformUtilsService, protected policyService: PolicyService, private logService: LogService, @@ -87,13 +179,6 @@ export class ImportComponent implements OnInit, OnDestroy { return this._importBlockedByPolicy; } - /** - * Callback that is called after a successful import. - */ - protected async onSuccessfulImport(): Promise { - await this.router.navigate(["vault"]); - } - ngOnInit() { this.setImportOptions(); @@ -167,6 +252,18 @@ export class ImportComponent implements OnInit, OnDestroy { }; protected async performImport() { + if (this.organization) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "warning" }, + content: { key: "importWarning", placeholders: [this.organization.name] }, + type: "warning", + }); + + if (!confirmed) { + return; + } + } + if (this.importBlockedByPolicy) { this.platformUtilsService.showToast( "error", @@ -246,7 +343,7 @@ export class ImportComponent implements OnInit, OnDestroy { }); this.syncService.fullSync(true); - await this.onSuccessfulImport(); + this.onSuccessfulImport.emit(this._organizationId); } catch (e) { this.dialogService.open(ImportErrorDialogComponent, { data: e, diff --git a/libs/importer/src/components/index.ts b/libs/importer/src/components/index.ts new file mode 100644 index 00000000000..a2e59f1714f --- /dev/null +++ b/libs/importer/src/components/index.ts @@ -0,0 +1,3 @@ +export * from "./dialog"; + +export { ImportComponent } from "./import.component"; diff --git a/libs/importer/src/index.ts b/libs/importer/src/index.ts index 4586407659e..4c6c4131ba4 100644 --- a/libs/importer/src/index.ts +++ b/libs/importer/src/index.ts @@ -1,11 +1,5 @@ -export { ImportType, ImportOption } from "./models/import-options"; +export * from "./models"; -export { ImportResult } from "./models/import-result"; - -export { ImportApiServiceAbstraction } from "./services/import-api.service.abstraction"; -export { ImportApiService } from "./services/import-api.service"; - -export { ImportServiceAbstraction } from "./services/import.service.abstraction"; -export { ImportService } from "./services/import.service"; +export * from "./services"; export { Importer } from "./importers/importer"; diff --git a/libs/importer/src/models/index.ts b/libs/importer/src/models/index.ts new file mode 100644 index 00000000000..95eb910957c --- /dev/null +++ b/libs/importer/src/models/index.ts @@ -0,0 +1,2 @@ +export { ImportType, ImportOption } from "./import-options"; +export { ImportResult } from "./import-result"; diff --git a/libs/importer/src/services/index.ts b/libs/importer/src/services/index.ts new file mode 100644 index 00000000000..7b1244867f5 --- /dev/null +++ b/libs/importer/src/services/index.ts @@ -0,0 +1,5 @@ +export { ImportApiServiceAbstraction } from "./import-api.service.abstraction"; +export { ImportApiService } from "./import-api.service"; + +export { ImportServiceAbstraction } from "./import.service.abstraction"; +export { ImportService } from "./import.service"; diff --git a/libs/shared/tsconfig.libs.json b/libs/shared/tsconfig.libs.json index 85d55542707..1addd88d4cc 100644 --- a/libs/shared/tsconfig.libs.json +++ b/libs/shared/tsconfig.libs.json @@ -7,7 +7,8 @@ "@bitwarden/common/*": ["../common/src/*"], "@bitwarden/components": ["../components/src"], "@bitwarden/exporter/*": ["../exporter/src/*"], - "@bitwarden/importer": ["../importer/src"], + "@bitwarden/importer/core": ["../importer/src"], + "@bitwarden/importer/ui": ["../importer/src/components"], "@bitwarden/node/*": ["../node/src/*"], "@bitwarden/vault": ["../vault/src"] } diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index d96a144ebcb..0ccac14430c 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -20,7 +20,8 @@ "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/components": ["./libs/components/src"], "@bitwarden/exporter/*": ["./libs/exporter/src/*"], - "@bitwarden/importer": ["./libs/importer/src"], + "@bitwarden/importer/core": ["./libs/importer/src"], + "@bitwarden/importer/ui": ["./libs/importer/src/components"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/vault": ["./libs/vault/src"] }, diff --git a/tsconfig.json b/tsconfig.json index d6e4db99f57..f6f6b4ee30f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/components": ["./libs/components/src"], "@bitwarden/exporter/*": ["./libs/exporter/src/*"], - "@bitwarden/importer": ["./libs/importer/src"], + "@bitwarden/importer/core": ["./libs/importer/src"], + "@bitwarden/importer/ui": ["./libs/importer/src/components"], "@bitwarden/node/*": ["./libs/node/src/*"], "@bitwarden/web-vault/*": ["./apps/web/src/*"], "@bitwarden/vault": ["./libs/vault/src"] From e357819251c241661e92f99af54126e0885405b3 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 19 Oct 2023 15:00:06 +0200 Subject: [PATCH 37/85] [PM-4197] Enable importing on deskop (#6502) * Split up import/export into separate modules * Fix routing and apply PR feedback * Renamed OrganizationExport exports to OrganizationVaultExport * Make import dialogs standalone and move them to libs/importer * Make import.component re-usable - Move functionality which was previously present on the org-import.component into import.component - Move import.component into libs/importer Make import.component standalone Create import-web.component to represent Web UI Fix module imports and routing Remove unused org-import-files * Enable importing on deskop Create import-dialog Create file-menu entry to open import-dialog Extend messages.json to include all the necessary messages from shared components * Renamed filenames according to export rename * Make ImportWebComponent standalone, simplify routing * Pass organizationId as Input to ImportComponent * use formLoading and formDisabled outputs * use formLoading & formDisabled in desktop * Emit an event when the import succeeds Remove Angular router from base-component as other clients might not have routing (i.e. desktop) Move logic that happened on web successful import into the import-web.component * Enable importing on deskop Create import-dialog Create file-menu entry to open import-dialog Extend messages.json to include all the necessary messages from shared components * use formLoading & formDisabled in desktop * Add missing message for importBlockedByPolicy callout * Remove commented code for submit button * Implement onSuccessfulImport to close dialog on success * fix table themes on desktop & browser * fix fileSelector button styles * update selectors to use tools prefix; remove unused selectors * update selectors * Wall off UI components in libs/importer Create barrel-file for libs/importer/components Remove components and dialog exports from libs/importer/index.ts Extend libs/shared/tsconfig.libs.json to include @bitwarden/importer/ui -> libs/importer/components Extend apps/web/tsconfig.ts to include @bitwarden/importer/ui Update all usages * Rename @bitwarden/importer to @bitwarden/importer/core Create more barrel files in libs/importer/* Update imports within libs/importer Extend tsconfig files Update imports in web, desktop, browser and cli * Lazy-load the ImportWebComponent via both routes * Fix import path for ImportComponent * Use SharedModule as import in import-web.component * File selector should be displayed as secondary * Add missing messages for file-password-prompt * Add missing messages for import-error-dialog * Add missing message for import-success-dialog * Use bitSubmit to override submit preventDefault (#6607) Co-authored-by: Daniel James Smith * Use large dialogSize * PM-4398 - Add missing importWarning --------- Co-authored-by: Daniel James Smith Co-authored-by: William Martin --- apps/desktop/src/app/app.component.ts | 4 + .../import/import-desktop.component.html | 26 ++++ .../tools/import/import-desktop.component.ts | 33 ++++++ apps/desktop/src/locales/en/messages.json | 111 ++++++++++++++++++ apps/desktop/src/main/menu/menu.file.ts | 10 ++ 5 files changed, 184 insertions(+) create mode 100644 apps/desktop/src/app/tools/import/import-desktop.component.html create mode 100644 apps/desktop/src/app/tools/import/import-desktop.component.ts diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 65ac83b59fd..b98dd0a8398 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -55,6 +55,7 @@ import { FolderAddEditComponent } from "../vault/app/vault/folder-add-edit.compo import { SettingsComponent } from "./accounts/settings.component"; import { ExportComponent } from "./tools/export/export.component"; import { GeneratorComponent } from "./tools/generator.component"; +import { ImportDesktopComponent } from "./tools/import/import-desktop.component"; import { PasswordGeneratorHistoryComponent } from "./tools/password-generator-history.component"; const BroadcasterSubscriptionId = "AppComponent"; @@ -328,6 +329,9 @@ export class AppComponent implements OnInit, OnDestroy { } this.messagingService.send("scheduleNextSync"); break; + case "importVault": + await this.dialogService.open(ImportDesktopComponent); + break; case "exportVault": await this.openExportVault(); break; diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.html b/apps/desktop/src/app/tools/import/import-desktop.component.html new file mode 100644 index 00000000000..74d4098255b --- /dev/null +++ b/apps/desktop/src/app/tools/import/import-desktop.component.html @@ -0,0 +1,26 @@ + + {{ "importData" | i18n }} + + + + + + + + diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.ts b/apps/desktop/src/app/tools/import/import-desktop.component.ts new file mode 100644 index 00000000000..62fc007731d --- /dev/null +++ b/apps/desktop/src/app/tools/import/import-desktop.component.ts @@ -0,0 +1,33 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; +import { ImportComponent } from "@bitwarden/importer/ui"; + +@Component({ + templateUrl: "import-desktop.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + DialogModule, + AsyncActionsModule, + ButtonModule, + ImportComponent, + ], +}) +export class ImportDesktopComponent { + protected disabled = false; + protected loading = false; + + constructor(public dialogRef: DialogRef) {} + + /** + * Callback that is called after a successful import. + */ + protected async onSuccessfulImport(organizationId: string): Promise { + this.dialogRef.close(); + } +} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 52fdbf1b560..537be07da97 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1662,6 +1662,9 @@ "personalOwnershipPolicyInEffect": { "message": "An organization policy is affecting your ownership options." }, + "personalOwnershipPolicyInEffectImports": { + "message": "An organization policy has blocked importing items into your individual vault." + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2427,5 +2430,113 @@ }, "aliasDomain": { "message": "Alias domain" + }, + "importData": { + "message": "Import data", + "description": "Used for the desktop menu item and the header of the import dialog" + }, + "importError": { + "message": "Import error" + }, + "importErrorDesc": { + "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + }, + "resolveTheErrorsBelowAndTryAgain": { + "message": "Resolve the errors below and try again." + }, + "description": { + "message": "Description" + }, + "importSuccess": { + "message": "Data successfully imported" + }, + "importSuccessNumberOfItems": { + "message": "A total of $AMOUNT$ items were imported.", + "placeholders": { + "amount": { + "content": "$1", + "example": "2" + } + } + }, + "total": { + "message": "Total" + }, + "importWarning": { + "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "importFormatError": { + "message": "Data is not formatted correctly. Please check your import file and try again." + }, + "importNothingError": { + "message": "Nothing was imported." + }, + "importEncKeyError": { + "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + }, + "importDestination": { + "message": "Import destination" + }, + "learnAboutImportOptions": { + "message": "Learn about your import options" + }, + "selectImportFolder": { + "message": "Select a folder" + }, + "selectImportCollection": { + "message": "Select a collection" + }, + "importTargetHint": { + "message": "Select this option if you want the imported file contents moved to a $DESTINATION$", + "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", + "placeholders": { + "destination": { + "content": "$1", + "example": "folder or collection" + } + } + }, + "importUnassignedItemsError": { + "message": "File contains unassigned items." + }, + "selectFormat": { + "message": "Select the format of the import file" + }, + "selectImportFile": { + "message": "Select the import file" + }, + "chooseFile": { + "message": "Choose File" + }, + "noFileChosen": { + "message": "No file chosen" + }, + "orCopyPasteFileContents": { + "message": "or copy/paste the import file contents" + }, + "instructionsFor": { + "message": "$NAME$ Instructions", + "description": "The title for the import tool instructions.", + "placeholders": { + "name": { + "content": "$1", + "example": "LastPass (csv)" + } + } + }, + "confirmVaultImport": { + "message": "Confirm vault import" + }, + "confirmVaultImportDesc": { + "message": "This file is password-protected. Please enter the file password to import data." + }, + "confirmFilePassword": { + "message": "Confirm file password" } } diff --git a/apps/desktop/src/main/menu/menu.file.ts b/apps/desktop/src/main/menu/menu.file.ts index 173b6066aba..0782039d7c2 100644 --- a/apps/desktop/src/main/menu/menu.file.ts +++ b/apps/desktop/src/main/menu/menu.file.ts @@ -24,6 +24,7 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { this.addNewFolder, this.separator, this.syncVault, + this.importVault, this.exportVault, ]; @@ -123,6 +124,15 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { }; } + private get importVault(): MenuItemConstructorOptions { + return { + id: "importVault", + label: this.localize("importData"), + click: () => this.sendMessage("importVault"), + enabled: !this._isLocked, + }; + } + private get exportVault(): MenuItemConstructorOptions { return { id: "exportVault", From 790d666929f67968fb16a443146b37ffe13298da Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 19 Oct 2023 20:21:53 +0200 Subject: [PATCH 38/85] [PM-4401] Fix zone.js patch compatibility issues in safari (#6633) * [PM-4401] fix: zone.js patch compatibility issues in safari * Update apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts Co-authored-by: Oscar Hinton --------- Co-authored-by: Oscar Hinton Co-authored-by: SmithThe4th --- .../polyfills/zone-patch-chrome-runtime.ts | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts b/apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts index fa731840f8b..055518fc751 100644 --- a/apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts +++ b/apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts @@ -2,35 +2,43 @@ * Monkey patch `chrome.runtime.onMessage` event listeners to run in the Angular zone. */ Zone.__load_patch("ChromeRuntimeOnMessage", (global: any, Zone: ZoneType, api: _ZonePrivate) => { - const onMessage = global.chrome.runtime.onMessage; if (typeof global?.chrome?.runtime?.onMessage === "undefined") { return; } + const onMessage = global.chrome.runtime.onMessage; // eslint-disable-next-line @typescript-eslint/ban-types - api.patchMethod(onMessage, "addListener", (delegate: Function) => (self: any, args: any[]) => { - const callback = args.length > 0 ? args[0] : null; - if (typeof callback === "function") { - const wrapperedCallback = Zone.current.wrap(callback, "ChromeRuntimeOnMessage"); - callback[api.symbol("chromeRuntimeOnMessageCallback")] = wrapperedCallback; - return delegate.call(self, wrapperedCallback); - } else { - return delegate.apply(self, args); - } + const nativeAddListener = onMessage.addListener as Function; + api.ObjectDefineProperty(chrome.runtime.onMessage, "addListener", { + value: function (...args: any[]) { + const callback = args.length > 0 ? args[0] : null; + if (typeof callback === "function") { + const wrapperedCallback = Zone.current.wrap(callback, "ChromeRuntimeOnMessage"); + callback[api.symbol("chromeRuntimeOnMessageCallback")] = wrapperedCallback; + return nativeAddListener.call(onMessage, wrapperedCallback); + } else { + return nativeAddListener.apply(onMessage, args); + } + }, + writable: false, }); // eslint-disable-next-line @typescript-eslint/ban-types - api.patchMethod(onMessage, "removeListener", (delegate: Function) => (self: any, args: any[]) => { - const callback = args.length > 0 ? args[0] : null; - if (typeof callback === "function") { - const wrapperedCallback = callback[api.symbol("chromeRuntimeOnMessageCallback")]; - if (wrapperedCallback) { - return delegate.call(self, wrapperedCallback); + const nativeRemoveListener = onMessage.removeListener as Function; + api.ObjectDefineProperty(chrome.runtime.onMessage, "removeListener", { + value: function (...args: any[]) { + const callback = args.length > 0 ? args[0] : null; + if (typeof callback === "function") { + const wrapperedCallback = callback[api.symbol("chromeRuntimeOnMessageCallback")]; + if (wrapperedCallback) { + return nativeRemoveListener.call(onMessage, wrapperedCallback); + } else { + return nativeRemoveListener.apply(onMessage, args); + } } else { - return delegate.apply(self, args); + return nativeRemoveListener.apply(onMessage, args); } - } else { - return delegate.apply(self, args); - } + }, + writable: false, }); }); From 13df63fbac4612f8f8b1a4a6011e4360fefb0957 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 19 Oct 2023 21:06:01 +0200 Subject: [PATCH 39/85] [PM-4195] Lastpass lib cleanup (#6636) * Casing fixes from the original port of the code * Add static createClientInfo and export * Add way to transform retrieve accounts into csv format Create ExportAccount model accountsToExportedCsvString can transform and export csv * Make calls needed for UI class async/awaitable * Add helpers for SSO on the UserTypeContext * Add additional error handling case * Fixes for SSO login --------- Co-authored-by: Daniel James Smith --- .../src/importers/lastpass/access/index.ts | 1 + .../lastpass/access/models/client-info.ts | 6 ++ .../access/models/exported-account.ts | 23 +++++++ .../importers/lastpass/access/models/index.ts | 1 + .../access/models/user-type-context.ts | 34 ++++++---- .../lastpass/access/services/client.ts | 20 +++--- .../lastpass/access/services/rest-client.ts | 6 +- .../src/importers/lastpass/access/ui/ui.ts | 12 ++-- .../src/importers/lastpass/access/vault.ts | 62 ++++++++++++------- 9 files changed, 116 insertions(+), 49 deletions(-) create mode 100644 libs/importer/src/importers/lastpass/access/models/exported-account.ts diff --git a/libs/importer/src/importers/lastpass/access/index.ts b/libs/importer/src/importers/lastpass/access/index.ts index a124a44b315..1ec8fe0df11 100644 --- a/libs/importer/src/importers/lastpass/access/index.ts +++ b/libs/importer/src/importers/lastpass/access/index.ts @@ -1 +1,2 @@ +export { ClientInfo } from "./models"; export { Vault } from "./vault"; diff --git a/libs/importer/src/importers/lastpass/access/models/client-info.ts b/libs/importer/src/importers/lastpass/access/models/client-info.ts index 275cdc00d3f..1f87512780c 100644 --- a/libs/importer/src/importers/lastpass/access/models/client-info.ts +++ b/libs/importer/src/importers/lastpass/access/models/client-info.ts @@ -1,7 +1,13 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + import { Platform } from "../enums"; export class ClientInfo { platform: Platform; id: string; description: string; + + static createClientInfo(): ClientInfo { + return { platform: Platform.Desktop, id: Utils.newGuid(), description: "Importer" }; + } } diff --git a/libs/importer/src/importers/lastpass/access/models/exported-account.ts b/libs/importer/src/importers/lastpass/access/models/exported-account.ts new file mode 100644 index 00000000000..3c42bbffc02 --- /dev/null +++ b/libs/importer/src/importers/lastpass/access/models/exported-account.ts @@ -0,0 +1,23 @@ +import { Account } from "./account"; + +export class ExportedAccount { + url: string; + username: string; + password: string; + totp: string; + extra: string; + name: string; + grouping: string; + fav: number; + + constructor(account: Account) { + this.url = account.url; + this.username = account.username; + this.password = account.password; + this.totp = account.totp; + this.extra = account.notes; + this.name = account.name; + this.grouping = account.path === "(none)" ? null : account.path; + this.fav = account.isFavorite ? 1 : 0; + } +} diff --git a/libs/importer/src/importers/lastpass/access/models/index.ts b/libs/importer/src/importers/lastpass/access/models/index.ts index a0c6121a354..9a3c5693ce5 100644 --- a/libs/importer/src/importers/lastpass/access/models/index.ts +++ b/libs/importer/src/importers/lastpass/access/models/index.ts @@ -1,6 +1,7 @@ export { Account } from "./account"; export { Chunk } from "./chunk"; export { ClientInfo } from "./client-info"; +export { ExportedAccount } from "./exported-account"; export { FederatedUserContext } from "./federated-user-context"; export { OobResult } from "./oob-result"; export { OtpResult } from "./otp-result"; diff --git a/libs/importer/src/importers/lastpass/access/models/user-type-context.ts b/libs/importer/src/importers/lastpass/access/models/user-type-context.ts index 9d849281c2d..a4e3c8668e5 100644 --- a/libs/importer/src/importers/lastpass/access/models/user-type-context.ts +++ b/libs/importer/src/importers/lastpass/access/models/user-type-context.ts @@ -2,24 +2,36 @@ import { IdpProvider, LastpassLoginType } from "../enums"; export class UserTypeContext { type: LastpassLoginType; - IdentityProviderGUID: string; - IdentityProviderURL: string; - OpenIDConnectAuthority: string; - OpenIDConnectClientId: string; - CompanyId: number; - Provider: IdpProvider; - PkceEnabled: boolean; - IsPasswordlessEnabled: boolean; + identityProviderGUID: string; + identityProviderURL: string; + openIDConnectAuthority: string; + openIDConnectClientId: string; + companyId: number; + provider: IdpProvider; + pkceEnabled: boolean; + isPasswordlessEnabled: boolean; isFederated(): boolean { return ( this.type === LastpassLoginType.Federated && - this.hasValue(this.IdentityProviderURL) && - this.hasValue(this.OpenIDConnectAuthority) && - this.hasValue(this.OpenIDConnectClientId) + this.hasValue(this.identityProviderURL) && + this.hasValue(this.openIDConnectAuthority) && + this.hasValue(this.openIDConnectClientId) ); } + get oidcScope(): string { + let scope = "openid profile email"; + if (this.provider === IdpProvider.PingOne) { + scope += " lastpass"; + } + return scope; + } + + get openIDConnectAuthorityBase(): string { + return this.openIDConnectAuthority.replace("/.well-known/openid-configuration", ""); + } + private hasValue(str: string) { return str != null && str.trim() !== ""; } diff --git a/libs/importer/src/importers/lastpass/access/services/client.ts b/libs/importer/src/importers/lastpass/access/services/client.ts index 2d8b503f01d..b185ada8881 100644 --- a/libs/importer/src/importers/lastpass/access/services/client.ts +++ b/libs/importer/src/importers/lastpass/access/services/client.ts @@ -229,13 +229,13 @@ export class Client { let passcode: OtpResult = null; switch (method) { case OtpMethod.GoogleAuth: - passcode = ui.provideGoogleAuthPasscode(); + passcode = await ui.provideGoogleAuthPasscode(); break; case OtpMethod.MicrosoftAuth: - passcode = ui.provideMicrosoftAuthPasscode(); + passcode = await ui.provideMicrosoftAuthPasscode(); break; case OtpMethod.Yubikey: - passcode = ui.provideYubikeyPasscode(); + passcode = await ui.provideYubikeyPasscode(); break; default: throw new Error("Invalid OTP method"); @@ -273,7 +273,7 @@ export class Client { ui: Ui, rest: RestClient ): Promise { - const answer = this.approveOob(username, parameters, ui, rest); + const answer = await this.approveOob(username, parameters, ui, rest); if (answer == OobResult.cancel) { throw new Error("Out of band step is canceled by the user"); } @@ -318,7 +318,12 @@ export class Client { return session; } - private approveOob(username: string, parameters: Map, ui: Ui, rest: RestClient) { + private async approveOob( + username: string, + parameters: Map, + ui: Ui, + rest: RestClient + ): Promise { const method = parameters.get("outofbandtype"); if (method == null) { throw new Error("Out of band method is not specified"); @@ -335,12 +340,12 @@ export class Client { } } - private approveDuo( + private async approveDuo( username: string, parameters: Map, ui: Ui, rest: RestClient - ): OobResult { + ): Promise { return parameters.get("preferduowebsdk") == "1" ? this.approveDuoWebSdk(username, parameters, ui, rest) : ui.approveDuo(); @@ -525,6 +530,7 @@ export class Client { switch (cause.value) { case "unknownemail": return "Invalid username"; + case "password_invalid": case "unknownpassword": return "Invalid password"; case "googleauthfailed": diff --git a/libs/importer/src/importers/lastpass/access/services/rest-client.ts b/libs/importer/src/importers/lastpass/access/services/rest-client.ts index b26109d8e87..ce5fede33cb 100644 --- a/libs/importer/src/importers/lastpass/access/services/rest-client.ts +++ b/libs/importer/src/importers/lastpass/access/services/rest-client.ts @@ -43,9 +43,6 @@ export class RestClient { ): Promise { const setBody = (requestInit: RequestInit, headerMap: Map) => { if (body != null) { - if (headerMap == null) { - headerMap = new Map(); - } headerMap.set("Content-Type", "application/json; charset=utf-8"); requestInit.body = JSON.stringify(body); } @@ -63,6 +60,9 @@ export class RestClient { method: "POST", credentials: "include", }; + if (headers == null) { + headers = new Map(); + } setBody(requestInit, headers); this.setHeaders(requestInit, headers, cookies); const request = new Request(this.baseUrl + "/" + endpoint, requestInit); diff --git a/libs/importer/src/importers/lastpass/access/ui/ui.ts b/libs/importer/src/importers/lastpass/access/ui/ui.ts index 2338e8a291e..b1640d325f1 100644 --- a/libs/importer/src/importers/lastpass/access/ui/ui.ts +++ b/libs/importer/src/importers/lastpass/access/ui/ui.ts @@ -4,9 +4,9 @@ import { DuoUi } from "./duo-ui"; export abstract class Ui extends DuoUi { // To cancel return OtpResult.Cancel, otherwise only valid data is expected. - provideGoogleAuthPasscode: () => OtpResult; - provideMicrosoftAuthPasscode: () => OtpResult; - provideYubikeyPasscode: () => OtpResult; + provideGoogleAuthPasscode: () => Promise; + provideMicrosoftAuthPasscode: () => Promise; + provideYubikeyPasscode: () => Promise; /* The UI implementations should provide the following possibilities for the user: @@ -23,7 +23,7 @@ export abstract class Ui extends DuoUi { passcode instead of performing an action in the app. In this case the UI should return OobResult.continueWithPasscode(passcode, rememberMe). */ - approveLastPassAuth: () => OobResult; - approveDuo: () => OobResult; - approveSalesforceAuth: () => OobResult; + approveLastPassAuth: () => Promise; + approveDuo: () => Promise; + approveSalesforceAuth: () => Promise; } diff --git a/libs/importer/src/importers/lastpass/access/vault.ts b/libs/importer/src/importers/lastpass/access/vault.ts index a461239eea8..fc38fab8714 100644 --- a/libs/importer/src/importers/lastpass/access/vault.ts +++ b/libs/importer/src/importers/lastpass/access/vault.ts @@ -1,3 +1,5 @@ +import * as papa from "papaparse"; + import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -7,6 +9,7 @@ import { IdpProvider } from "./enums"; import { Account, ClientInfo, + ExportedAccount, FederatedUserContext, ParserOptions, UserTypeContext, @@ -68,20 +71,35 @@ export class Vault { if (response.status === HttpStatusCode.Ok) { const json = await response.json(); this.userType = new UserTypeContext(); - this.userType.CompanyId = json.CompanyId; - this.userType.IdentityProviderGUID = json.IdentityProviderGUID; - this.userType.IdentityProviderURL = json.IdentityProviderURL; - this.userType.IsPasswordlessEnabled = json.IsPasswordlessEnabled; - this.userType.OpenIDConnectAuthority = json.OpenIDConnectAuthority; - this.userType.OpenIDConnectClientId = json.OpenIDConnectClientId; - this.userType.PkceEnabled = json.PkceEnabled; - this.userType.Provider = json.Provider; + this.userType.companyId = json.CompanyId; + this.userType.identityProviderGUID = json.IdentityProviderGUID; + this.userType.identityProviderURL = json.IdentityProviderURL; + this.userType.isPasswordlessEnabled = json.IsPasswordlessEnabled; + this.userType.openIDConnectAuthority = json.OpenIDConnectAuthority; + this.userType.openIDConnectClientId = json.OpenIDConnectClientId; + this.userType.pkceEnabled = json.PkceEnabled; + this.userType.provider = json.Provider; this.userType.type = json.type; return; } throw new Error("Cannot determine LastPass user type."); } + accountsToExportedCsvString(skipShared = false): string { + if (this.accounts == null) { + throw new Error("Vault has not opened any accounts."); + } + + const exportedAccounts = this.accounts + .filter((a) => !a.isShared || (a.isShared && !skipShared)) + .map((a) => new ExportedAccount(a)); + + if (exportedAccounts.length === 0) { + throw new Error("No accounts to transform"); + } + return papa.unparse(exportedAccounts); + } + private async getK1(federatedUser: FederatedUserContext): Promise { if (this.userType == null) { throw new Error("User type is not set."); @@ -96,18 +114,18 @@ export class Vault { } let k1: Uint8Array = null; - if (federatedUser.idpUserInfo?.LastPassK1 !== null) { + if (federatedUser.idpUserInfo?.LastPassK1 != null) { return Utils.fromByteStringToArray(federatedUser.idpUserInfo.LastPassK1); - } else if (this.userType.Provider === IdpProvider.Azure) { + } else if (this.userType.provider === IdpProvider.Azure) { k1 = await this.getK1Azure(federatedUser); - } else if (this.userType.Provider === IdpProvider.Google) { + } else if (this.userType.provider === IdpProvider.Google) { k1 = await this.getK1Google(federatedUser); } else { - const b64Encoded = this.userType.Provider === IdpProvider.PingOne; - k1 = this.getK1FromAccessToken(federatedUser, b64Encoded); + const b64Encoded = this.userType.provider === IdpProvider.PingOne; + k1 = await this.getK1FromAccessToken(federatedUser, b64Encoded); } - if (k1 !== null) { + if (k1 != null) { return k1; } @@ -125,7 +143,7 @@ export class Vault { if (response.status === HttpStatusCode.Ok) { const json = await response.json(); const k1 = json?.extensions?.LastPassK1 as string; - if (k1 !== null) { + if (k1 != null) { return Utils.fromB64ToArray(k1); } } @@ -149,7 +167,7 @@ export class Vault { if (response.status === HttpStatusCode.Ok) { const json = await response.json(); const files = json?.files as any[]; - if (files !== null && files.length > 0 && files[0].id != null && files[0].name === "k1.lp") { + if (files != null && files.length > 0 && files[0].id != null && files[0].name === "k1.lp") { // Open the k1.lp file rest.baseUrl = "https://www.googleapis.com"; const response = await rest.get( @@ -165,10 +183,10 @@ export class Vault { return null; } - private getK1FromAccessToken(federatedUser: FederatedUserContext, b64: boolean) { - const decodedAccessToken = this.tokenService.decodeToken(federatedUser.accessToken); + private async getK1FromAccessToken(federatedUser: FederatedUserContext, b64: boolean) { + const decodedAccessToken = await this.tokenService.decodeToken(federatedUser.accessToken); const k1 = decodedAccessToken?.LastPassK1 as string; - if (k1 !== null) { + if (k1 != null) { return b64 ? Utils.fromB64ToArray(k1) : Utils.fromByteStringToArray(k1); } return null; @@ -184,15 +202,15 @@ export class Vault { } const rest = new RestClient(); - rest.baseUrl = this.userType.IdentityProviderURL; + rest.baseUrl = this.userType.identityProviderURL; const response = await rest.postJson("federatedlogin/api/v1/getkey", { - company_id: this.userType.CompanyId, + company_id: this.userType.companyId, id_token: federatedUser.idToken, }); if (response.status === HttpStatusCode.Ok) { const json = await response.json(); const k2 = json?.k2 as string; - if (k2 !== null) { + if (k2 != null) { return Utils.fromB64ToArray(k2); } } From cdcd1809f0aaa2390c4a3dcd0f49776b3415c16b Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Thu, 19 Oct 2023 15:41:01 -0400 Subject: [PATCH 40/85] Expand account service (#6622) * Define account service observable responsibilities * Establish account service observables and update methods * Update Account Service observables from state service This is a temporary stop-gap to avoid needing to reroute all account activity and status changes through the account service. That can be done as part of the breakup of state service. * Add matchers for Observable emissions * Fix null active account * Test account service * Transition account status to account info * Remove unused matchers * Remove duplicate class * Replay active account for late subscriptions * Add factories for background services * Fix state service for web * Allow for optional messaging This is a temporary hack until the flow of account status can be reversed from state -> account to account -> state. The foreground account service will still logout, it's just the background one cannot send messages * Fix add account logic * Do not throw on recoverable errors It's possible that duplicate entries exist in `activeAccounts` exist in the wild. If we throw on adding a duplicate account this will cause applications to be unusable until duplicates are removed it is not necessary to throw since this is recoverable. with some potential loss in current account status * Add documentation to abstraction * Update libs/common/spec/utils.ts Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * Fix justin's comment :fist-shake: --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- .../account-service.factory.ts | 38 ++++ .../browser/src/background/main.background.ts | 7 +- .../state-service.factory.ts | 8 +- .../services/browser-state.service.spec.ts | 4 + .../services/browser-state.service.ts | 3 + .../src/popup/services/services.module.ts | 15 +- apps/cli/src/bw.ts | 8 +- .../src/app/services/services.module.ts | 2 + apps/desktop/src/main.ts | 2 + apps/web/src/app/core/state/state.service.ts | 3 + .../src/services/jslib-services.module.ts | 1 + libs/common/spec/utils.ts | 38 ++++ .../src/auth/abstractions/account.service.ts | 48 ++++- .../src/auth/services/account.service.spec.ts | 181 ++++++++++++++++++ .../src/auth/services/account.service.ts | 81 +++++++- .../src/platform/services/state.service.ts | 30 ++- libs/common/src/types/guid.d.ts | 5 + 17 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 apps/browser/src/auth/background/service-factories/account-service.factory.ts create mode 100644 libs/common/src/auth/services/account.service.spec.ts create mode 100644 libs/common/src/types/guid.d.ts diff --git a/apps/browser/src/auth/background/service-factories/account-service.factory.ts b/apps/browser/src/auth/background/service-factories/account-service.factory.ts new file mode 100644 index 00000000000..759ff8efdd5 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/account-service.factory.ts @@ -0,0 +1,38 @@ +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; + +import { + FactoryOptions, + CachedServices, + factory, +} from "../../../platform/background/service-factories/factory-options"; +import { + LogServiceInitOptions, + logServiceFactory, +} from "../../../platform/background/service-factories/log-service.factory"; +import { + MessagingServiceInitOptions, + messagingServiceFactory, +} from "../../../platform/background/service-factories/messaging-service.factory"; + +type AccountServiceFactoryOptions = FactoryOptions; + +export type AccountServiceInitOptions = AccountServiceFactoryOptions & + MessagingServiceInitOptions & + LogServiceInitOptions; + +export function accountServiceFactory( + cache: { accountService?: AccountService } & CachedServices, + opts: AccountServiceInitOptions +): Promise { + return factory( + cache, + "accountService", + opts, + async () => + new AccountServiceImplementation( + await messagingServiceFactory(cache, opts), + await logServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9d4cba04e7c..5c47c1aaf9e 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -14,6 +14,7 @@ import { InternalPolicyService as InternalPolicyServiceAbstraction } from "@bitw import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; +import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -24,6 +25,7 @@ import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/ import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; @@ -225,6 +227,7 @@ export default class MainBackground { authRequestCryptoService: AuthRequestCryptoServiceAbstraction; popupUtilsService: PopupUtilsService; browserPopoutWindowService: BrowserPopoutWindowService; + accountService: AccountServiceAbstraction; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -279,12 +282,14 @@ export default class MainBackground { new KeyGenerationService(this.cryptoFunctionService) ) : new MemoryStorageService(); + this.accountService = new AccountServiceImplementation(this.messagingService, this.logService); this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, this.memoryStorageService, this.logService, - new StateFactory(GlobalState, Account) + new StateFactory(GlobalState, Account), + this.accountService ); this.platformUtilsService = new BrowserPlatformUtilsService( this.messagingService, diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts index 7d3aaf9b6f3..31a0316c09a 100644 --- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -1,6 +1,10 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { + accountServiceFactory, + AccountServiceInitOptions, +} from "../../../auth/background/service-factories/account-service.factory"; import { Account } from "../../../models/account"; import { BrowserStateService } from "../../services/browser-state.service"; @@ -26,7 +30,8 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & DiskStorageServiceInitOptions & SecureStorageServiceInitOptions & MemoryStorageServiceInitOptions & - LogServiceInitOptions; + LogServiceInitOptions & + AccountServiceInitOptions; export async function stateServiceFactory( cache: { stateService?: BrowserStateService } & CachedServices, @@ -43,6 +48,7 @@ export async function stateServiceFactory( await memoryStorageServiceFactory(cache, opts), await logServiceFactory(cache, opts), opts.stateServiceOptions.stateFactory, + await accountServiceFactory(cache, opts), opts.stateServiceOptions.useAccountCache ) ); diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index 0712416172c..c63aae74036 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -1,5 +1,6 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractMemoryStorageService, @@ -27,6 +28,7 @@ describe("Browser State Service", () => { let logService: MockProxy; let stateFactory: MockProxy>; let useAccountCache: boolean; + let accountService: MockProxy; let state: State; const userId = "userId"; @@ -38,6 +40,7 @@ describe("Browser State Service", () => { diskStorageService = mock(); logService = mock(); stateFactory = mock(); + accountService = mock(); // turn off account cache for tests useAccountCache = false; @@ -62,6 +65,7 @@ describe("Browser State Service", () => { memoryStorageService, logService, stateFactory, + accountService, useAccountCache ); }); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index ec6851beb8f..ae5abb8a897 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,5 +1,6 @@ import { BehaviorSubject } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService, @@ -42,6 +43,7 @@ export class BrowserStateService memoryStorageService: AbstractMemoryStorageService, logService: LogService, stateFactory: StateFactory, + accountService: AccountService, useAccountCache = true ) { super( @@ -50,6 +52,7 @@ export class BrowserStateService memoryStorageService, logService, stateFactory, + accountService, useAccountCache ); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 2622b8ef13b..06235809458 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -23,6 +23,7 @@ import { } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; +import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -453,17 +454,25 @@ function getBgService(service: keyof MainBackground) { storageService: AbstractStorageService, secureStorageService: AbstractStorageService, memoryStorageService: AbstractMemoryStorageService, - logService: LogServiceAbstraction + logService: LogServiceAbstraction, + accountService: AccountServiceAbstraction ) => { return new BrowserStateService( storageService, secureStorageService, memoryStorageService, logService, - new StateFactory(GlobalState, Account) + new StateFactory(GlobalState, Account), + accountService ); }, - deps: [AbstractStorageService, SECURE_STORAGE, MEMORY_STORAGE, LogServiceAbstraction], + deps: [ + AbstractStorageService, + SECURE_STORAGE, + MEMORY_STORAGE, + LogServiceAbstraction, + AccountServiceAbstraction, + ], }, { provide: UsernameGenerationServiceAbstraction, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index ffaec215e26..b63dda690f7 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -12,9 +12,11 @@ import { OrganizationService } from "@bitwarden/common/admin-console/services/or 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 } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; +import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; @@ -152,6 +154,7 @@ export class Main { authRequestCryptoService: AuthRequestCryptoServiceAbstraction; configApiService: ConfigApiServiceAbstraction; configService: CliConfigService; + accountService: AccountService; constructor() { let p = null; @@ -191,12 +194,15 @@ export class Main { this.memoryStorageService = new MemoryStorageService(); + this.accountService = new AccountServiceImplementation(null, this.logService); + this.stateService = new StateService( this.storageService, this.secureStorageService, this.memoryStorageService, this.logService, - new StateFactory(GlobalState, Account) + new StateFactory(GlobalState, Account), + this.accountService ); this.cryptoService = new CryptoService( diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index f4841073c9c..c586d8677c3 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -11,6 +11,7 @@ import { import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; @@ -120,6 +121,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); MEMORY_STORAGE, LogService, STATE_FACTORY, + AccountServiceAbstraction, STATE_SERVICE_USE_CACHE, ], }, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1eb229281c8..1c4f415e1c8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2,6 +2,7 @@ import * as path from "path"; import { app } from "electron"; +import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; @@ -93,6 +94,7 @@ export class Main { this.memoryStorageService, this.logService, new StateFactory(GlobalState, Account), + new AccountServiceImplementation(null, this.logService), // will not broadcast logouts. This is a hack until we can remove messaging dependency false // Do not use disk caching because this will get out of sync with the renderer service ); diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index c95077bfbcc..4848ad4fb78 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -6,6 +6,7 @@ import { STATE_FACTORY, STATE_SERVICE_USE_CACHE, } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractMemoryStorageService, @@ -30,6 +31,7 @@ export class StateService extends BaseStateService { @Inject(MEMORY_STORAGE) memoryStorageService: AbstractMemoryStorageService, logService: LogService, @Inject(STATE_FACTORY) stateFactory: StateFactory, + accountService: AccountService, @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true ) { super( @@ -38,6 +40,7 @@ export class StateService extends BaseStateService { memoryStorageService, logService, stateFactory, + accountService, useAccountCache ); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index e52e9c394e4..060593d4a53 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -489,6 +489,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; MEMORY_STORAGE, LogService, STATE_FACTORY, + AccountServiceAbstraction, STATE_SERVICE_USE_CACHE, ], }, diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 3cab011c6b0..8d6f8920318 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { Observable } from "rxjs"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; @@ -40,3 +41,40 @@ export function makeStaticByteArray(length: number, start = 0) { * Use to mock a return value of a static fromJSON method. */ export const mockFromJson = (stub: any) => (stub + "_fromJSON") as any; + +/** + * Tracks the emissions of the given observable. + * + * Call this function before you expect any emissions and then use code that will cause the observable to emit values, + * then assert after all expected emissions have occurred. + * @param observable + * @returns An array that will be populated with all emissions of the observable. + */ +export function trackEmissions(observable: Observable): T[] { + const emissions: T[] = []; + observable.subscribe((value) => { + switch (value) { + case undefined: + case null: + emissions.push(value); + return; + default: + // process by type + break; + } + + switch (typeof value) { + case "string": + case "number": + case "boolean": + emissions.push(value); + break; + case "object": + emissions.push({ ...value }); + break; + default: + emissions.push(JSON.parse(JSON.stringify(value))); + } + }); + return emissions; +} diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 26c260eb6d5..30fe32e2597 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -1,4 +1,50 @@ -export abstract class AccountService {} +import { Observable } from "rxjs"; + +import { UserId } from "../../types/guid"; +import { AuthenticationStatus } from "../enums/authentication-status"; + +export type AccountInfo = { + status: AuthenticationStatus; + email: string; + name: string | undefined; +}; + +export abstract class AccountService { + accounts$: Observable>; + activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>; + accountLock$: Observable; + accountLogout$: Observable; + /** + * Updates the `accounts$` observable with the new account data. + * @param userId + * @param accountData + */ + abstract addAccount(userId: UserId, accountData: AccountInfo): void; + /** + * updates the `accounts$` observable with the new preferred name for the account. + * @param userId + * @param name + */ + abstract setAccountName(userId: UserId, name: string): void; + /** + * updates the `accounts$` observable with the new email for the account. + * @param userId + * @param email + */ + abstract setAccountEmail(userId: UserId, email: string): void; + /** + * Updates the `accounts$` observable with the new account status. + * Also emits the `accountLock$` or `accountLogout$` observable if the status is `Locked` or `LoggedOut` respectively. + * @param userId + * @param status + */ + abstract setAccountStatus(userId: UserId, status: AuthenticationStatus): void; + /** + * Updates the `activeAccount$` observable with the new active account. + * @param userId + */ + abstract switchAccount(userId: UserId): void; +} export abstract class InternalAccountService extends AccountService { abstract delete(): void; diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts new file mode 100644 index 00000000000..3b28f39cf11 --- /dev/null +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -0,0 +1,181 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { trackEmissions } from "../../../spec/utils"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { UserId } from "../../types/guid"; +import { AccountInfo } from "../abstractions/account.service"; +import { AuthenticationStatus } from "../enums/authentication-status"; + +import { AccountServiceImplementation } from "./account.service"; + +describe("accountService", () => { + let messagingService: MockProxy; + let logService: MockProxy; + let sut: AccountServiceImplementation; + const userId = "userId" as UserId; + function userInfo(status: AuthenticationStatus): AccountInfo { + return { status, email: "email", name: "name" }; + } + + beforeEach(() => { + messagingService = mock(); + logService = mock(); + + sut = new AccountServiceImplementation(messagingService, logService); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("activeAccount$", () => { + it("should emit undefined if no account is active", () => { + const emissions = trackEmissions(sut.activeAccount$); + + expect(emissions).toEqual([undefined]); + }); + + it("should emit the active account and status", async () => { + const emissions = trackEmissions(sut.activeAccount$); + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + sut.switchAccount(userId); + + expect(emissions).toEqual([ + undefined, // initial value + { id: userId, ...userInfo(AuthenticationStatus.Unlocked) }, + ]); + }); + + it("should remember the last emitted value", async () => { + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + sut.switchAccount(userId); + + expect(await firstValueFrom(sut.activeAccount$)).toEqual({ + id: userId, + ...userInfo(AuthenticationStatus.Unlocked), + }); + }); + }); + + describe("addAccount", () => { + it("should emit the new account", () => { + const emissions = trackEmissions(sut.accounts$); + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + + expect(emissions).toEqual([ + {}, // initial value + { [userId]: userInfo(AuthenticationStatus.Unlocked) }, + ]); + }); + }); + + describe("setAccountName", () => { + beforeEach(() => { + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + }); + + it("should emit the updated account", () => { + const emissions = trackEmissions(sut.accounts$); + sut.setAccountName(userId, "new name"); + + expect(emissions).toEqual([ + { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "name" } }, + { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" } }, + ]); + }); + }); + + describe("setAccountEmail", () => { + beforeEach(() => { + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + }); + + it("should emit the updated account", () => { + const emissions = trackEmissions(sut.accounts$); + sut.setAccountEmail(userId, "new email"); + + expect(emissions).toEqual([ + { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "email" } }, + { [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" } }, + ]); + }); + }); + + describe("setAccountStatus", () => { + beforeEach(() => { + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + }); + + it("should not emit if the status is the same", async () => { + const emissions = trackEmissions(sut.accounts$); + sut.setAccountStatus(userId, AuthenticationStatus.Unlocked); + sut.setAccountStatus(userId, AuthenticationStatus.Unlocked); + + expect(emissions).toEqual([{ userId: userInfo(AuthenticationStatus.Unlocked) }]); + }); + + it("should maintain an accounts cache", async () => { + expect(await firstValueFrom(sut.accounts$)).toEqual({ + [userId]: userInfo(AuthenticationStatus.Unlocked), + }); + }); + + it("should emit if the status is different", () => { + const emissions = trackEmissions(sut.accounts$); + sut.setAccountStatus(userId, AuthenticationStatus.Locked); + + expect(emissions).toEqual([ + { userId: userInfo(AuthenticationStatus.Unlocked) }, // initial value from beforeEach + { userId: userInfo(AuthenticationStatus.Locked) }, + ]); + }); + + it("should emit logout if the status is logged out", () => { + const emissions = trackEmissions(sut.accountLogout$); + sut.setAccountStatus(userId, AuthenticationStatus.LoggedOut); + + expect(emissions).toEqual([userId]); + }); + + it("should emit lock if the status is locked", () => { + const emissions = trackEmissions(sut.accountLock$); + sut.setAccountStatus(userId, AuthenticationStatus.Locked); + + expect(emissions).toEqual([userId]); + }); + }); + + describe("switchAccount", () => { + let emissions: { id: string; status: AuthenticationStatus }[]; + + beforeEach(() => { + emissions = []; + sut.activeAccount$.subscribe((value) => emissions.push(value)); + }); + + it("should emit undefined if no account is provided", () => { + sut.switchAccount(undefined); + + expect(emissions).toEqual([undefined]); + }); + + it("should emit the active account and status", () => { + sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + sut.switchAccount(userId); + sut.setAccountStatus(userId, AuthenticationStatus.Locked); + sut.switchAccount(undefined); + sut.switchAccount(undefined); + expect(emissions).toEqual([ + undefined, // initial value + { id: userId, ...userInfo(AuthenticationStatus.Unlocked) }, + { id: userId, ...userInfo(AuthenticationStatus.Locked) }, + ]); + }); + + it("should throw if switched to an unknown account", () => { + expect(() => sut.switchAccount(userId)).toThrowError("Account does not exist"); + }); + }); +}); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 02c12050952..33388218db3 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -1,16 +1,93 @@ -import { InternalAccountService } from "../../auth/abstractions/account.service"; +import { + BehaviorSubject, + Subject, + combineLatestWith, + map, + distinctUntilChanged, + shareReplay, +} from "rxjs"; + +import { AccountInfo, InternalAccountService } from "../../auth/abstractions/account.service"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { UserId } from "../../types/guid"; +import { AuthenticationStatus } from "../enums/authentication-status"; export class AccountServiceImplementation implements InternalAccountService { + private accounts = new BehaviorSubject>({}); + private activeAccountId = new BehaviorSubject(undefined); + private lock = new Subject(); + private logout = new Subject(); + + accounts$ = this.accounts.asObservable(); + activeAccount$ = this.activeAccountId.pipe( + combineLatestWith(this.accounts$), + map(([id, accounts]) => (id ? { id, ...accounts[id] } : undefined)), + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: false }) + ); + accountLock$ = this.lock.asObservable(); + accountLogout$ = this.logout.asObservable(); constructor(private messagingService: MessagingService, private logService: LogService) {} + addAccount(userId: UserId, accountData: AccountInfo): void { + this.accounts.value[userId] = accountData; + this.accounts.next(this.accounts.value); + } + + setAccountName(userId: UserId, name: string): void { + this.setAccountInfo(userId, { ...this.accounts.value[userId], name }); + } + + setAccountEmail(userId: UserId, email: string): void { + this.setAccountInfo(userId, { ...this.accounts.value[userId], email }); + } + + setAccountStatus(userId: UserId, status: AuthenticationStatus): void { + this.setAccountInfo(userId, { ...this.accounts.value[userId], status }); + + if (status === AuthenticationStatus.LoggedOut) { + this.logout.next(userId); + } else if (status === AuthenticationStatus.Locked) { + this.lock.next(userId); + } + } + + switchAccount(userId: UserId) { + if (userId == null) { + // indicates no account is active + this.activeAccountId.next(undefined); + return; + } + + if (this.accounts.value[userId] == null) { + throw new Error("Account does not exist"); + } + this.activeAccountId.next(userId); + } + + // TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow async delete(): Promise { try { - this.messagingService.send("logout"); + this.messagingService?.send("logout"); } catch (e) { this.logService.error(e); throw e; } } + + private setAccountInfo(userId: UserId, accountInfo: AccountInfo) { + if (this.accounts.value[userId] == null) { + throw new Error("Account does not exist"); + } + + // Avoid unnecessary updates + // TODO: Faster comparison, maybe include a hash on the objects? + if (JSON.stringify(this.accounts.value[userId]) === JSON.stringify(accountInfo)) { + return; + } + + this.accounts.value[userId] = accountInfo; + this.accounts.next(this.accounts.value); + } } diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index d0983448d62..c8d45b6d4e3 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -6,6 +6,8 @@ import { OrganizationData } from "../../admin-console/models/data/organization.d import { PolicyData } from "../../admin-console/models/data/policy.data"; import { ProviderData } from "../../admin-console/models/data/provider.data"; import { Policy } from "../../admin-console/models/domain/policy"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason"; @@ -27,6 +29,7 @@ import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/ import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { SendData } from "../../tools/send/models/data/send.data"; import { SendView } from "../../tools/send/models/view/send.view"; +import { UserId } from "../../types/guid"; import { CipherData } from "../../vault/models/data/cipher.data"; import { CollectionData } from "../../vault/models/data/collection.data"; import { FolderData } from "../../vault/models/data/folder.data"; @@ -110,6 +113,7 @@ export class StateService< protected memoryStorageService: AbstractMemoryStorageService, protected logService: LogService, protected stateFactory: StateFactory, + protected accountService: AccountService, protected useAccountCache: boolean = true ) { // If the account gets changed, verify the new account is unlocked @@ -168,6 +172,8 @@ export class StateService< } await this.pushAccounts(); this.activeAccountSubject.next(state.activeUserId); + // TODO: Temporary update to avoid routing all account status changes through account service for now. + this.accountService.switchAccount(state.activeUserId as UserId); return state; }); @@ -184,6 +190,12 @@ export class StateService< state.accounts[userId] = this.createAccount(); const diskAccount = await this.getAccountFromDisk({ userId: userId }); state.accounts[userId].profile = diskAccount.profile; + // TODO: Temporary update to avoid routing all account status changes through account service for now. + this.accountService.addAccount(userId as UserId, { + status: AuthenticationStatus.Locked, + name: diskAccount.profile.name, + email: diskAccount.profile.email, + }); return state; }); } @@ -198,6 +210,12 @@ export class StateService< }); await this.scaffoldNewAccountStorage(account); await this.setLastActive(new Date().getTime(), { userId: account.profile.userId }); + // TODO: Temporary update to avoid routing all account status changes through account service for now. + this.accountService.addAccount(account.profile.userId as UserId, { + status: AuthenticationStatus.Locked, + name: account.profile.name, + email: account.profile.email, + }); await this.setActiveUser(account.profile.userId); this.activeAccountSubject.next(account.profile.userId); } @@ -208,6 +226,9 @@ export class StateService< state.activeUserId = userId; await this.storageService.save(keys.activeUserId, userId); this.activeAccountSubject.next(state.activeUserId); + // TODO: temporary update to avoid routing all account status changes through account service for now. + this.accountService.switchAccount(userId as UserId); + return state; }); @@ -548,6 +569,9 @@ export class StateService< this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); + const nextStatus = value != null ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked; + this.accountService.setAccountStatus(options.userId as UserId, nextStatus); + if (options.userId == this.activeAccountSubject.getValue()) { const nextValue = value != null; @@ -581,6 +605,9 @@ export class StateService< this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); + const nextStatus = value != null ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked; + this.accountService.setAccountStatus(options.userId as UserId, nextStatus); + if (options?.userId == this.activeAccountSubject.getValue()) { const nextValue = value != null; @@ -3062,7 +3089,6 @@ export class StateService< this.reconcileOptions({ userId: account.profile.userId }, await this.defaultOnDiskOptions()) ); } - // protected async pushAccounts(): Promise { await this.pruneInMemoryAccounts(); @@ -3180,6 +3206,8 @@ export class StateService< return state; }); + // TODO: Invert this logic, we should remove accounts based on logged out emit + this.accountService.setAccountStatus(userId as UserId, AuthenticationStatus.LoggedOut); } protected async pruneInMemoryAccounts() { diff --git a/libs/common/src/types/guid.d.ts b/libs/common/src/types/guid.d.ts new file mode 100644 index 00000000000..f77655a95f3 --- /dev/null +++ b/libs/common/src/types/guid.d.ts @@ -0,0 +1,5 @@ +import { Opaque } from "type-fest"; + +type Guid = Opaque; + +type UserId = Opaque; From 87dbe8997dfa8dc78c310a8adf9c1df4ac1edc47 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:33:41 +0200 Subject: [PATCH 41/85] [PM-4209] Enable importing on browser (#6503) * Split up import/export into separate modules * Fix routing and apply PR feedback * Renamed OrganizationExport exports to OrganizationVaultExport * Make import dialogs standalone and move them to libs/importer * Make import.component re-usable - Move functionality which was previously present on the org-import.component into import.component - Move import.component into libs/importer Make import.component standalone Create import-web.component to represent Web UI Fix module imports and routing Remove unused org-import-files * Enable importing on browser Create import-dialog Add routing and routing animations Settings import items no longer navigates to help page but opens import page Extend messages.json to include all the necessary messages from shared components * Fix back navigation * Renamed filenames according to export rename * Make ImportWebComponent standalone, simplify routing * Pass organizationId as Input to ImportComponent * use formLoading and formDisabled outputs * add loading and disabled state to import-browser * override popup header styles * Emit an event when the import succeeds Remove Angular router from base-component as other clients might not have routing (i.e. desktop) Move logic that happened on web successful import into the import-web.component * Enable importing on browser Create import-dialog Add routing and routing animations Settings import items no longer navigates to help page but opens import page Extend messages.json to include all the necessary messages from shared components * Fix back navigation * add loading and disabled state to import-browser * override popup header styles * Add missing message for importBlockedByPolicy callout * Implement onSuccessfulImport to navigate back to settings * fix table themes on desktop & browser * fix fileSelector button styles * update selectors to use tools prefix; remove unused selectors * rename selector * Wall off UI components in libs/importer Create barrel-file for libs/importer/components Remove components and dialog exports from libs/importer/index.ts Extend libs/shared/tsconfig.libs.json to include @bitwarden/importer/ui -> libs/importer/components Extend apps/web/tsconfig.ts to include @bitwarden/importer/ui Update all usages * Rename @bitwarden/importer to @bitwarden/importer/core Create more barrel files in libs/importer/* Update imports within libs/importer Extend tsconfig files Update imports in web, desktop, browser and cli * Lazy-load the ImportWebComponent via both routes * Fix import path for ImportComponent * Navigate to import opens in popout when navigated from the popup Make import call async and await router navigate - If the user has the popup open and selects import, it will navigate to the import page and popout into a new window. This is necessary as any focus-loss (i.e Choose file) would close the popup. - If the user is using the for example the sidebar or an already popped out window, just navigate to import page * Use SharedModule as import in import-web.component * File selector should be displayed as secondary * Update description of "importData" in messages.json * Add missing messages for file-password-prompt * Add missing messages for import-error-dialog * Add missing message for import-success-dialog * Use bitSubmit to override submit preventDefault (#6607) Co-authored-by: Daniel James Smith * Add missing importWarning --------- Co-authored-by: Daniel James Smith Co-authored-by: William Martin --- apps/browser/src/_locales/en/messages.json | 111 ++++++++++++++++++ .../src/popup/app-routing.animations.ts | 3 + apps/browser/src/popup/app-routing.module.ts | 7 ++ apps/browser/src/popup/scss/base.scss | 2 +- .../src/popup/settings/settings.component.ts | 7 +- .../import/import-browser.component.html | 24 ++++ .../import/import-browser.component.ts | 31 +++++ 7 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 apps/browser/src/tools/popup/settings/import/import-browser.component.html create mode 100644 apps/browser/src/tools/popup/settings/import/import-browser.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 9a68f8bb9fb..24e1bc3ce30 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1656,6 +1656,9 @@ "personalOwnershipPolicyInEffect": { "message": "An organization policy is affecting your ownership options." }, + "personalOwnershipPolicyInEffectImports": { + "message": "An organization policy has blocked importing items into your individual vault." + }, "excludedDomains": { "message": "Excluded domains" }, @@ -2449,6 +2452,114 @@ "message": "Turn off master password re-prompt to edit this field", "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." }, + "importData": { + "message": "Import data", + "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" + }, + "importError": { + "message": "Import error" + }, + "importErrorDesc": { + "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + }, + "resolveTheErrorsBelowAndTryAgain": { + "message": "Resolve the errors below and try again." + }, + "description": { + "message": "Description" + }, + "importSuccess": { + "message": "Data successfully imported" + }, + "importSuccessNumberOfItems": { + "message": "A total of $AMOUNT$ items were imported.", + "placeholders": { + "amount": { + "content": "$1", + "example": "2" + } + } + }, + "total": { + "message": "Total" + }, + "importWarning": { + "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "importFormatError": { + "message": "Data is not formatted correctly. Please check your import file and try again." + }, + "importNothingError": { + "message": "Nothing was imported." + }, + "importEncKeyError": { + "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + }, + "importDestination": { + "message": "Import destination" + }, + "learnAboutImportOptions": { + "message": "Learn about your import options" + }, + "selectImportFolder": { + "message": "Select a folder" + }, + "selectImportCollection": { + "message": "Select a collection" + }, + "importTargetHint": { + "message": "Select this option if you want the imported file contents moved to a $DESTINATION$", + "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", + "placeholders": { + "destination": { + "content": "$1", + "example": "folder or collection" + } + } + }, + "importUnassignedItemsError": { + "message": "File contains unassigned items." + }, + "selectFormat": { + "message": "Select the format of the import file" + }, + "selectImportFile": { + "message": "Select the import file" + }, + "chooseFile": { + "message": "Choose File" + }, + "noFileChosen": { + "message": "No file chosen" + }, + "orCopyPasteFileContents": { + "message": "or copy/paste the import file contents" + }, + "instructionsFor": { + "message": "$NAME$ Instructions", + "description": "The title for the import tool instructions.", + "placeholders": { + "name": { + "content": "$1", + "example": "LastPass (csv)" + } + } + }, + "confirmVaultImport": { + "message": "Confirm vault import" + }, + "confirmVaultImportDesc": { + "message": "This file is password-protected. Please enter the file password to import data." + }, + "confirmFilePassword": { + "message": "Confirm file password" + }, "passkeyNotCopied": { "message": "Passkey will not be copied" }, diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index 2304944acb0..42baf65c270 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -174,6 +174,9 @@ export const routerTransition = trigger("routerTransition", [ transition("clone-cipher => attachments, clone-cipher => collections", inSlideLeft), transition("attachments => clone-cipher, collections => clone-cipher", outSlideRight), + transition("tabs => import", inSlideLeft), + transition("import => tabs", outSlideRight), + transition("tabs => export", inSlideLeft), transition("export => tabs", outSlideRight), diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 10159a715f0..df7b9ffb1cf 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -32,6 +32,7 @@ import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.componen import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component"; import { ExportComponent } from "../tools/popup/settings/export.component"; +import { ImportBrowserComponent } from "../tools/popup/settings/import/import-browser.component"; import { Fido2Component } from "../vault/popup/components/fido2/fido2.component"; import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component"; import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component"; @@ -222,6 +223,12 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "generator-history" }, }, + { + path: "import", + component: ImportBrowserComponent, + canActivate: [AuthGuard], + data: { state: "import" }, + }, { path: "export", component: ExportComponent, diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index 3b401d356f5..6cd99abb0d5 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -175,7 +175,7 @@ cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb, } } -header { +header:not(bit-callout header) { min-height: 44px; max-height: 44px; display: flex; diff --git a/apps/browser/src/popup/settings/settings.component.ts b/apps/browser/src/popup/settings/settings.component.ts index 274ed871225..252a2097156 100644 --- a/apps/browser/src/popup/settings/settings.component.ts +++ b/apps/browser/src/popup/settings/settings.component.ts @@ -473,8 +473,11 @@ export class SettingsComponent implements OnInit { BrowserApi.createNewTab(url); } - import() { - BrowserApi.createNewTab("https://bitwarden.com/help/import-data/"); + async import() { + await this.router.navigate(["/import"]); + if (await BrowserApi.isPopupOpen()) { + this.popupUtilsService.popOut(window); + } } export() { diff --git a/apps/browser/src/tools/popup/settings/import/import-browser.component.html b/apps/browser/src/tools/popup/settings/import/import-browser.component.html new file mode 100644 index 00000000000..b305e6c395f --- /dev/null +++ b/apps/browser/src/tools/popup/settings/import/import-browser.component.html @@ -0,0 +1,24 @@ +
+
+ +
+

+ {{ "importData" | i18n }} +

+
+ +
+
+
+ +
diff --git a/apps/browser/src/tools/popup/settings/import/import-browser.component.ts b/apps/browser/src/tools/popup/settings/import/import-browser.component.ts new file mode 100644 index 00000000000..3fea3aad04b --- /dev/null +++ b/apps/browser/src/tools/popup/settings/import/import-browser.component.ts @@ -0,0 +1,31 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { Router, RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; +import { ImportComponent } from "@bitwarden/importer/ui"; + +@Component({ + templateUrl: "import-browser.component.html", + standalone: true, + imports: [ + CommonModule, + RouterLink, + JslibModule, + DialogModule, + AsyncActionsModule, + ButtonModule, + ImportComponent, + ], +}) +export class ImportBrowserComponent { + protected disabled = false; + protected loading = false; + + constructor(private router: Router) {} + + protected async onSuccessfulImport(organizationId: string): Promise { + this.router.navigate(["/tabs/settings"]); + } +} From e9f0c07b02c539a365bb68c678c31f1ba4e04dd8 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Thu, 19 Oct 2023 17:56:51 -0400 Subject: [PATCH 42/85] [SM-949] Add Event Logs to Service Account (#6546) * Add Event Logs to Service Account * Update bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Add takeUntil import * add service account access guard --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Co-authored-by: Thomas Avery --- .../service-account-event-log-api.service.ts | 43 +++++++ .../service-accounts-events.component.html | 105 ++++++++++++++++++ .../service-accounts-events.component.ts | 77 +++++++++++++ .../guards/service-account-access.guard.ts | 28 +++++ .../service-account.component.html | 1 + .../service-accounts-routing.module.ts | 7 ++ .../service-accounts.module.ts | 2 + 7 files changed, 263 insertions(+) create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-account-event-log-api.service.ts create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.html create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts create mode 100644 bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-account-event-log-api.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-account-event-log-api.service.ts new file mode 100644 index 00000000000..669c063e988 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-account-event-log-api.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventResponse } from "@bitwarden/common/models/response/event.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; + +@Injectable({ + providedIn: "root", +}) +export class ServiceAccountEventLogApiService { + constructor(private apiService: ApiService) {} + + async getEvents( + serviceAccountId: string, + start: string, + end: string, + token: string + ): Promise> { + const r = await this.apiService.send( + "GET", + this.addEventParameters("/sm/events/service-accounts/" + serviceAccountId, start, end, token), + null, + true, + true + ); + return new ListResponse(r, EventResponse); + } + + private addEventParameters(base: string, start: string, end: string, token: string) { + if (start != null) { + base += "?start=" + start; + } + if (end != null) { + base += base.indexOf("?") > -1 ? "&" : "?"; + base += "end=" + end; + } + if (token != null) { + base += base.indexOf("?") > -1 ? "&" : "?"; + base += "continuationToken=" + token; + } + return base; + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.html new file mode 100644 index 00000000000..e5a7ce64da8 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.html @@ -0,0 +1,105 @@ +
+

{{ "eventLogs" | i18n }}

+
+ + {{ "from" | i18n }} + + + - + + {{ "to" | i18n }} + + +
+ +
+
+ +
+
+
+ + + {{ "loading" | i18n }} + + +

{{ "noEventsInList" | i18n }}

+ + + + {{ "timestamp" | i18n }} + {{ "client" | i18n }} + {{ "event" | i18n }} + + + + + {{ e.date | date : "medium" }} + + {{ e.appName }} + + + + + + +
diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts new file mode 100644 index 00000000000..652272ecd1a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts @@ -0,0 +1,77 @@ +import { Component, OnDestroy } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; + +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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { BaseEventsComponent } from "@bitwarden/web-vault/app/common/base.events.component"; +import { EventService } from "@bitwarden/web-vault/app/core"; +import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export"; + +import { ServiceAccountEventLogApiService } from "./service-account-event-log-api.service"; + +@Component({ + selector: "sm-service-accounts-events", + templateUrl: "./service-accounts-events.component.html", +}) +export class ServiceAccountEventsComponent extends BaseEventsComponent implements OnDestroy { + exportFileName = "service-account-events"; + private destroy$ = new Subject(); + private serviceAccountId: string; + + constructor( + eventService: EventService, + private serviceAccountEventsApiService: ServiceAccountEventLogApiService, + private route: ActivatedRoute, + i18nService: I18nService, + exportService: EventExportService, + platformUtilsService: PlatformUtilsService, + logService: LogService, + fileDownloadService: FileDownloadService + ) { + super( + eventService, + i18nService, + exportService, + platformUtilsService, + logService, + fileDownloadService + ); + } + + async ngOnInit() { + // eslint-disable-next-line rxjs/no-async-subscribe + this.route.params.pipe(takeUntil(this.destroy$)).subscribe(async (params) => { + this.serviceAccountId = params.serviceAccountId; + await this.load(); + }); + } + + async load() { + await this.loadEvents(true); + this.loaded = true; + } + + protected requestEvents(startDate: string, endDate: string, continuationToken: string) { + return this.serviceAccountEventsApiService.getEvents( + this.serviceAccountId, + startDate, + endDate, + continuationToken + ); + } + + protected getUserName() { + return { + name: this.i18nService.t("serviceAccount") + " " + this.serviceAccountId, + email: "", + }; + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts new file mode 100644 index 00000000000..a1c54a6bfaa --- /dev/null +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.ts @@ -0,0 +1,28 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, createUrlTreeFromSnapshot } from "@angular/router"; + +import { ServiceAccountService } from "../service-account.service"; + +/** + * Redirects to service accounts page if the user doesn't have access to service account. + */ +export const serviceAccountAccessGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const serviceAccountService = inject(ServiceAccountService); + + try { + const serviceAccount = await serviceAccountService.getByServiceAccountId( + route.params.serviceAccountId, + route.params.organizationId + ); + if (serviceAccount) { + return true; + } + } catch { + return createUrlTreeFromSnapshot(route, [ + "/sm", + route.params.organizationId, + "service-accounts", + ]); + } + return createUrlTreeFromSnapshot(route, ["/sm", route.params.organizationId, "service-accounts"]); +}; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html index 7d6304b5a0e..8b7991b1214 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.html @@ -13,6 +13,7 @@ {{ "projects" | i18n }} {{ "people" | i18n }} {{ "accessTokens" | i18n }} + {{ "eventLogs" | i18n }}
-
diff --git a/apps/browser/src/tools/popup/settings/import/import-browser.component.ts b/apps/browser/src/tools/popup/settings/import/import-browser.component.ts index 30ead216b50..3fea3aad04b 100644 --- a/apps/browser/src/tools/popup/settings/import/import-browser.component.ts +++ b/apps/browser/src/tools/popup/settings/import/import-browser.component.ts @@ -1,14 +1,11 @@ import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { Router, RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components"; import { ImportComponent } from "@bitwarden/importer/ui"; -import { FilePopoutCalloutComponent } from "../../components/file-popout-callout.component"; -import { FilePopoutUtilsService } from "../../services/file-popout-utils.service"; - @Component({ templateUrl: "import-browser.component.html", standalone: true, @@ -20,20 +17,13 @@ import { FilePopoutUtilsService } from "../../services/file-popout-utils.service AsyncActionsModule, ButtonModule, ImportComponent, - FilePopoutCalloutComponent, ], }) -export class ImportBrowserComponent implements OnInit { +export class ImportBrowserComponent { protected disabled = false; protected loading = false; - protected hideFileSelector = false; - - constructor(private router: Router, private filePopoutUtilsService: FilePopoutUtilsService) {} - - ngOnInit(): void { - this.hideFileSelector = this.filePopoutUtilsService.showFilePopoutMessage(window); - } + constructor(private router: Router) {} protected async onSuccessfulImport(organizationId: string): Promise { this.router.navigate(["/tabs/settings"]); diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index ab9250a4c73..83e119fcc57 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -344,7 +344,7 @@ and save the zip file.
- + {{ "selectImportFile" | i18n }}
+
+ + +
diff --git a/apps/desktop/src/app/tools/generator.component.html b/apps/desktop/src/app/tools/generator.component.html index 1b2ee9df42e..0c66ebde805 100644 --- a/apps/desktop/src/app/tools/generator.component.html +++ b/apps/desktop/src/app/tools/generator.component.html @@ -380,6 +380,16 @@

(blur)="saveUsernameOptions()" />

+
+ + +
diff --git a/apps/web/src/app/tools/generator.component.html b/apps/web/src/app/tools/generator.component.html index c690a458dab..2e6d6d0effd 100644 --- a/apps/web/src/app/tools/generator.component.html +++ b/apps/web/src/app/tools/generator.component.html @@ -283,6 +283,17 @@

{{ "generator" | i18n }}

(blur)="saveUsernameOptions()" />
+
+ + +
diff --git a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts index cca6dd34dd1..00d1717bf60 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts @@ -4,6 +4,7 @@ export class ForwarderOptions { fastmail = new FastmailForwarderOptions(); anonaddy = new AnonAddyForwarderOptions(); forwardemail = new ForwardEmailForwarderOptions(); + simplelogin = new SimpleLoginForwarderOptions(); } export class FastmailForwarderOptions { @@ -18,3 +19,7 @@ export class AnonAddyForwarderOptions { export class ForwardEmailForwarderOptions { domain: string; } + +export class SimpleLoginForwarderOptions { + baseUrl: string; +} diff --git a/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts index 7ecd72dc59c..4d5b7749d49 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts @@ -17,7 +17,7 @@ export class SimpleLoginForwarder implements Forwarder { "Content-Type": "application/json", }), }; - let url = "https://app.simplelogin.io/api/alias/random/new"; + let url = options.simplelogin.baseUrl + "/api/alias/random/new"; if (options.website != null) { url += "?hostname=" + options.website; } diff --git a/libs/common/src/tools/generator/username/username-generation-options.ts b/libs/common/src/tools/generator/username/username-generation-options.ts index 970f7e945e3..276668de96a 100644 --- a/libs/common/src/tools/generator/username/username-generation-options.ts +++ b/libs/common/src/tools/generator/username/username-generation-options.ts @@ -17,4 +17,5 @@ export type UsernameGeneratorOptions = { forwardedForwardEmailApiToken?: string; forwardedForwardEmailDomain?: string; forwardedSimpleLoginApiKey?: string; + forwardedSimpleLoginBaseUrl?: string; }; diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts index 35a3a73da90..e28ffb12221 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.ts @@ -26,6 +26,7 @@ const DefaultOptions: UsernameGeneratorOptions = { forwardedAnonAddyDomain: "anonaddy.me", forwardedAnonAddyBaseUrl: "https://app.addy.io", forwardedForwardEmailDomain: "hideaddress.net", + forwardedSimpleLoginBaseUrl: "https://app.simplelogin.io", }; export class UsernameGenerationService implements UsernameGenerationServiceAbstraction { @@ -128,6 +129,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr if (o.forwardedService === "simplelogin") { forwarder = new SimpleLoginForwarder(); forwarderOptions.apiKey = o.forwardedSimpleLoginApiKey; + forwarderOptions.simplelogin.baseUrl = o.forwardedSimpleLoginBaseUrl; } else if (o.forwardedService === "anonaddy") { forwarder = new AnonAddyForwarder(); forwarderOptions.apiKey = o.forwardedAnonAddyApiToken; From 95d4d281cb1eb3171f31e277df0a5b771cc2f889 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 23 Oct 2023 11:01:59 -0400 Subject: [PATCH 59/85] [AC-1706] Show Discounted Prices (#6668) * Removed subscription copy from org and individual * Discount all prices in subscription components --- .../user-subscription.component.html | 11 ------ .../individual/user-subscription.component.ts | 4 --- ...nization-subscription-cloud.component.html | 14 ++------ ...ganization-subscription-cloud.component.ts | 35 +++++++++++++++---- .../sm-subscribe-standalone.component.html | 1 + .../sm-subscribe-standalone.component.ts | 2 ++ .../billing/shared/sm-subscribe.component.ts | 19 +++++++--- apps/web/src/locales/en/messages.json | 6 ---- .../organization-subscription.response.ts | 28 ++++++++++++--- .../models/response/subscription.response.ts | 14 -------- 10 files changed, 73 insertions(+), 61 deletions(-) diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index dca77dbf950..4c600b421c2 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -90,17 +90,6 @@

{{ i.quantity * i.amount | currency : "$" }} /{{ i.interval | i18n }} - - - - {{ "customBillingStart" | i18n }} - - {{ "billingHistory" | i18n }} - - {{ "customBillingEnd" | i18n }} - - -

diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 5b4b7cf49ef..abb5fd06428 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -205,10 +205,6 @@ export class UserSubscriptionComponent implements OnInit { return this.sub != null ? this.sub.upcomingInvoice : null; } - get discount() { - return this.sub != null ? this.sub.discount : null; - } - get storagePercentage() { return this.sub != null && this.sub.maxStorageGb ? +(100 * (this.sub.storageGb / this.sub.maxStorageGb)).toFixed(2) diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index bfb94a389ed..62d17a7e00d 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -69,7 +69,7 @@

- + {{ i.productName }} - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ @@ -79,17 +79,6 @@

{{ i.quantity * i.amount | currency : "$" }} /{{ i.interval | i18n }} - - - - {{ "customBillingStart" | i18n }} - - {{ "billingHistory" | i18n }} - - {{ "customBillingEnd" | i18n }} - - - @@ -150,6 +139,7 @@

diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index eb7180f7c83..d9e81e5f6b0 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -134,12 +134,24 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.sub != null ? this.sub.subscription : null; } + get subscriptionLineItems() { + return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({ + name: lineItem.name, + amount: this.discountPrice(lineItem.amount), + quantity: lineItem.quantity, + interval: lineItem.interval, + sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem, + addonSubscriptionItem: lineItem.addonSubscriptionItem, + productName: lineItem.productName, + })); + } + get nextInvoice() { return this.sub != null ? this.sub.upcomingInvoice : null; } - get discount() { - return this.sub != null ? this.sub.discount : null; + get customerDiscount() { + return this.sub != null ? this.sub.customerDiscount : null; } get isExpired() { @@ -168,11 +180,11 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } get storageGbPrice() { - return this.sub.plan.PasswordManager.additionalStoragePricePerGb; + return this.discountPrice(this.sub.plan.PasswordManager.additionalStoragePricePerGb); } get seatPrice() { - return this.sub.plan.PasswordManager.seatPrice; + return this.discountPrice(this.sub.plan.PasswordManager.seatPrice); } get seats() { @@ -183,12 +195,14 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return { seatCount: this.sub.smSeats, maxAutoscaleSeats: this.sub.maxAutoscaleSmSeats, - seatPrice: this.sub.plan.SecretsManager.seatPrice, + seatPrice: this.discountPrice(this.sub.plan.SecretsManager.seatPrice), maxAutoscaleServiceAccounts: this.sub.maxAutoscaleSmServiceAccounts, additionalServiceAccounts: this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, interval: this.sub.plan.isAnnual ? "year" : "month", - additionalServiceAccountPrice: this.sub.plan.SecretsManager.additionalPricePerServiceAccount, + additionalServiceAccountPrice: this.discountPrice( + this.sub.plan.SecretsManager.additionalPricePerServiceAccount + ), baseServiceAccountCount: this.sub.plan.SecretsManager.baseServiceAccount, }; } @@ -382,6 +396,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } }; + discountPrice = (price: number) => { + const discount = + !!this.customerDiscount && this.customerDiscount.active + ? price * (this.customerDiscount.percentOff / 100) + : 0; + + return price - discount; + }; + get showChangePlanButton() { return this.subscription == null && this.sub.planType === PlanType.Free && !this.showChangePlan; } diff --git a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.html b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.html index 84c74ee4282..2f3a2c08e30 100644 --- a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.html +++ b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.html @@ -4,5 +4,6 @@ [selectedPlan]="plan" [upgradeOrganization]="false" [showSubmitButton]="true" + [customerDiscount]="customerDiscount" > diff --git a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts index 2942a67560f..ebde4ab2536 100644 --- a/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts +++ b/apps/web/src/app/billing/organizations/sm-subscribe-standalone.component.ts @@ -6,6 +6,7 @@ import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin- import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { SecretsManagerSubscribeRequest } from "@bitwarden/common/billing/models/request/sm-subscribe.request"; +import { BillingCustomerDiscount } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -19,6 +20,7 @@ import { secretsManagerSubscribeFormFactory } from "../shared"; export class SecretsManagerSubscribeStandaloneComponent { @Input() plan: PlanResponse; @Input() organization: Organization; + @Input() customerDiscount: BillingCustomerDiscount; @Output() onSubscribe = new EventEmitter(); formGroup = secretsManagerSubscribeFormFactory(this.formBuilder); diff --git a/apps/web/src/app/billing/shared/sm-subscribe.component.ts b/apps/web/src/app/billing/shared/sm-subscribe.component.ts index 1aa6c1bccb5..85836bf17f2 100644 --- a/apps/web/src/app/billing/shared/sm-subscribe.component.ts +++ b/apps/web/src/app/billing/shared/sm-subscribe.component.ts @@ -3,6 +3,7 @@ import { FormBuilder, FormGroup, Validators } from "@angular/forms"; import { Subject, startWith, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; +import { BillingCustomerDiscount } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { ProductType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -36,6 +37,7 @@ export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy { @Input() upgradeOrganization: boolean; @Input() showSubmitButton = false; @Input() selectedPlan: PlanResponse; + @Input() customerDiscount: BillingCustomerDiscount; logo = SecretsManagerLogo; productTypes = ProductType; @@ -63,6 +65,15 @@ export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + discountPrice = (price: number) => { + const discount = + !!this.customerDiscount && this.customerDiscount.active + ? price * (this.customerDiscount.percentOff / 100) + : 0; + + return price - discount; + }; + get product() { return this.selectedPlan.product; } @@ -84,8 +95,8 @@ export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy { get monthlyCostPerServiceAccount() { return this.selectedPlan.isAnnual - ? this.selectedPlan.SecretsManager.additionalPricePerServiceAccount / 12 - : this.selectedPlan.SecretsManager.additionalPricePerServiceAccount; + ? this.discountPrice(this.selectedPlan.SecretsManager.additionalPricePerServiceAccount) / 12 + : this.discountPrice(this.selectedPlan.SecretsManager.additionalPricePerServiceAccount); } get maxUsers() { @@ -98,7 +109,7 @@ export class SecretsManagerSubscribeComponent implements OnInit, OnDestroy { get monthlyCostPerUser() { return this.selectedPlan.isAnnual - ? this.selectedPlan.SecretsManager.seatPrice / 12 - : this.selectedPlan.SecretsManager.seatPrice; + ? this.discountPrice(this.selectedPlan.SecretsManager.seatPrice) / 12 + : this.discountPrice(this.selectedPlan.SecretsManager.seatPrice); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5b4b2ac8625..3f7b1d1b05e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7273,12 +7273,6 @@ "alreadyHaveAccount": { "message": "Already have an account?" }, - "customBillingStart": { - "message": "Custom billing is not reflected. Visit the " - }, - "customBillingEnd": { - "message": " page for latest invoicing." - }, "typePasskey": { "message": "Passkey" }, diff --git a/libs/common/src/billing/models/response/organization-subscription.response.ts b/libs/common/src/billing/models/response/organization-subscription.response.ts index a86adbabe7c..404540c6165 100644 --- a/libs/common/src/billing/models/response/organization-subscription.response.ts +++ b/libs/common/src/billing/models/response/organization-subscription.response.ts @@ -1,9 +1,9 @@ import { OrganizationResponse } from "../../../admin-console/models/response/organization.response"; +import { BaseResponse } from "../../../models/response/base.response"; import { BillingSubscriptionResponse, BillingSubscriptionUpcomingInvoiceResponse, - BillingCustomerDiscount, } from "./subscription.response"; export class OrganizationSubscriptionResponse extends OrganizationResponse { @@ -11,7 +11,7 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse { storageGb: number; subscription: BillingSubscriptionResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; - discount: BillingCustomerDiscount; + customerDiscount: BillingCustomerDiscount; expiration: string; expirationWithoutGracePeriod: string; secretsManagerBeta: boolean; @@ -27,10 +27,30 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse { upcomingInvoice == null ? null : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); - const discount = this.getResponseProperty("Discount"); - this.discount = discount == null ? null : new BillingCustomerDiscount(discount); + const customerDiscount = this.getResponseProperty("CustomerDiscount"); + this.customerDiscount = + customerDiscount == null ? null : new BillingCustomerDiscount(customerDiscount); this.expiration = this.getResponseProperty("Expiration"); this.expirationWithoutGracePeriod = this.getResponseProperty("ExpirationWithoutGracePeriod"); this.secretsManagerBeta = this.getResponseProperty("SecretsManagerBeta"); } } + +export class BillingCustomerDiscount extends BaseResponse { + id: string; + active: boolean; + percentOff?: number; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.active = this.getResponseProperty("Active"); + this.percentOff = this.getResponseProperty("PercentOff"); + } + + discountPrice = (price: number) => { + const discount = this !== null && this.active ? price * (this.percentOff / 100) : 0; + + return price - discount; + }; +} diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index fd84cf493d4..d6bff87adb0 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -6,7 +6,6 @@ export class SubscriptionResponse extends BaseResponse { maxStorageGb: number; subscription: BillingSubscriptionResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; - discount: BillingCustomerDiscount; license: any; expiration: string; usingInAppPurchase: boolean; @@ -21,13 +20,11 @@ export class SubscriptionResponse extends BaseResponse { this.usingInAppPurchase = this.getResponseProperty("UsingInAppPurchase"); const subscription = this.getResponseProperty("Subscription"); const upcomingInvoice = this.getResponseProperty("UpcomingInvoice"); - const discount = this.getResponseProperty("Discount"); this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription); this.upcomingInvoice = upcomingInvoice == null ? null : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); - this.discount = discount == null ? null : new BillingCustomerDiscount(discount); } } @@ -89,14 +86,3 @@ export class BillingSubscriptionUpcomingInvoiceResponse extends BaseResponse { this.amount = this.getResponseProperty("Amount"); } } - -export class BillingCustomerDiscount extends BaseResponse { - id: string; - active: boolean; - - constructor(response: any) { - super(response); - this.id = this.getResponseProperty("Id"); - this.active = this.getResponseProperty("Active"); - } -} From c2e03d2cdc8943e1d95a13a517a192a081906e0a Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 23 Oct 2023 17:22:07 +0200 Subject: [PATCH 60/85] [PM-2052] Migrate bulk restore component (#6604) --- .../bulk/bulk-restore-revoke.component.html | 183 ++++++++---------- .../bulk/bulk-restore-revoke.component.ts | 30 +-- .../organizations/members/people.component.ts | 13 +- 3 files changed, 109 insertions(+), 117 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.html index db8af13ed27..d05fed4f922 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-restore-revoke.component.html @@ -1,101 +1,88 @@ -