diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 55f822e623..0013234faa 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -659,7 +659,7 @@ jobs: - name: Download artifact from hotfix-rc if: github.ref == 'refs/heads/hotfix-rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -668,7 +668,7 @@ jobs: - name: Download artifact from rc if: github.ref == 'refs/heads/rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -677,7 +677,7 @@ jobs: - name: Download artifacts from main if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -864,7 +864,7 @@ jobs: - name: Download artifact from hotfix-rc if: github.ref == 'refs/heads/hotfix-rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -873,7 +873,7 @@ jobs: - name: Download artifact from rc if: github.ref == 'refs/heads/rc' - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success @@ -882,7 +882,7 @@ jobs: - name: Download artifact from main if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: dawidd6/action-download-artifact@71072fbb1229e1317f1a8de6b04206afb461bd67 # v3.1.2 + uses: bitwarden/gh-actions/download-artifacts@main with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c650d8a62..12649b91ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,10 +48,12 @@ jobs: # Tests in apps/ are typechecked when their app is built, so we just do it here for libs/ # See https://bitwarden.atlassian.net/browse/EC-497 - name: Run typechecking - run: npm run test:types --coverage + run: npm run test:types - name: Run tests - run: npm run test --coverage + # maxWorkers is a workaround for a memory leak that crashes tests in CI: + # https://github.com/facebook/jest/issues/9430#issuecomment-1149882002 + run: npm test -- --coverage --maxWorkers=3 - name: Report test results uses: dorny/test-reporter@eaa763f6ffc21c7a37837f56cd5f9737f27fc6c8 # v1.8.0 diff --git a/apps/browser/package.json b/apps/browser/package.json index 14c1ed6b1d..a295a0f5bf 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.5.2", + "version": "2024.6.0", "scripts": { "build": "cross-env MANIFEST_VERSION=3 webpack", "build:mv2": "webpack", diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 76615435f7..129aac00ee 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -763,7 +763,7 @@ "message": "Kilidi aç" }, "additionalOptions": { - "message": "Additional options" + "message": "Əlavə seçimlər" }, "enableContextMenuItem": { "message": "Konteks menyu seçimlərini göstər" @@ -803,7 +803,7 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportFrom": { - "message": "Export from" + "message": "Buradan xaricə köçür" }, "exportVault": { "message": "Anbarı xaricə köçür" @@ -812,28 +812,28 @@ "message": "Fayl formatı" }, "fileEncryptedExportWarningDesc": { - "message": "This file export will be password protected and require the file password to decrypt." + "message": "Bu faylın xaricə köçürülməsi, parolla qorunacaq və şifrəsini açmaq üçün fayl parolu tələb olunacaq." }, "filePassword": { - "message": "File password" + "message": "Fayl parolu" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "Bu parol, bu faylı daxilə və xaricə köçürmək üçün istifadə olunacaq" }, "accountRestrictedOptionDescription": { - "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + "message": "Xaricə köçürməni şifrələmək və daxilə köçürməni yalnız mövcud Bitwarden hesabı ilə məhdudlaşdırmaq üçün hesabınızın istifadəçi adı və Ana Parolundan əldə edilən hesab şifrələmə açarınızı istifadə edin." }, "passwordProtectedOptionDescription": { - "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + "message": "Xaricə köçürməni şifrələmək üçün bir fayl parolu təyin edin və şifrəni açma parolunu istifadə edərək bunu istənilən Bitwarden hesabına köçürün." }, "exportTypeHeading": { - "message": "Export type" + "message": "Xaricə köçürmə növü" }, "accountRestricted": { - "message": "Account restricted" + "message": "Hesab məhdudlaşdırıldı" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "“File password” and “Confirm file password“ do not match." + "message": "\"Fayl parolu\" və \"Fayl parolunu təsdiqlə\" uyuşmur." }, "warning": { "message": "XƏBƏRDARLIQ", @@ -2213,10 +2213,10 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "Təşkilat anbarını xaricə köçürmə" }, "exportingOrganizationVaultDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat anbarı ixrac ediləcək. Fərdi anbardakı və digər təşkilat elementlər daxil edilmir.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 05c731aa37..bec6702ae2 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -821,7 +821,7 @@ "message": "Dieses Passwort wird zum Exportieren und Importieren dieser Datei verwendet" }, "accountRestrictedOptionDescription": { - "message": "Verwende den Verschlüsselungscode deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken." + "message": "Verwende den Verschlüsselungsschlüssel deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken." }, "passwordProtectedOptionDescription": { "message": "Lege ein Dateipasswort fest, um den Export zu verschlüsseln und importiere ihn in ein beliebiges Bitwarden-Konto, wobei das Passwort zum Entschlüsseln genutzt wird." diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 426f570d64..deb7410a71 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -224,7 +224,7 @@ }, "continueToAuthenticatorPageDesc": { "message": "Bitwarden Authenticator allows you to store authenticator keys and generate TOTP codes for 2-step verification flows. Learn more on the bitwarden.com website" - }, + }, "bitwardenSecretsManager": { "message": "Bitwarden Secrets Manager" }, @@ -599,6 +599,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, @@ -1107,6 +1110,15 @@ "selfHostedEnvironmentFooter": { "message": "Specify the base URL of your on-premises hosted Bitwarden installation." }, + "selfHostedBaseUrlHint": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" + }, + "selfHostedCustomEnvHeader" :{ + "message": "For advanced configuration, you can specify the base URL of each service independently." + }, + "selfHostedEnvFormInvalid" :{ + "message": "You must add either the base Server URL or at least one custom environment." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1744,6 +1756,12 @@ "ok": { "message": "Ok" }, + "errorRefreshingAccessToken":{ + "message": "Access Token Refresh Error" + }, + "errorRefreshingAccessTokenDesc":{ + "message": "No refresh token or API keys found. Please try logging out and logging back in." + }, "desktopSyncVerificationTitle": { "message": "Desktop sync verification" }, @@ -3333,5 +3351,14 @@ "example": "Work" } } + }, + "itemsWithNoFolder": { + "message": "Items with no folder" + }, + "organizationIsDeactivated": { + "message": "Organization is deactivated" + }, + "contactYourOrgAdmin": { + "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index e44efc99da..5b426e47d1 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -423,7 +423,7 @@ "message": "Kita" }, "unlockMethods": { - "message": "Unlock options" + "message": "Atrakinti parinktis" }, "unlockMethodNeededToChangeTimeoutActionDesc": { "message": "Nustatyk atrakinimo būdą, kad pakeistum saugyklos laiko limito veiksmą." @@ -432,10 +432,10 @@ "message": "Nustatykite nustatymuose atrakinimo metodą" }, "sessionTimeoutHeader": { - "message": "Session timeout" + "message": "Baigėsi seanso laikas" }, "otherOptions": { - "message": "Other options" + "message": "Kitos parinktys" }, "rateExtension": { "message": "Įvertinkite šį plėtinį" @@ -2274,7 +2274,7 @@ "message": "Sugeneruoti el. pašto slapyvardį su išorine persiuntimo paslauga." }, "forwarderError": { - "message": "$SERVICENAME$ error: $ERRORMESSAGE$", + "message": "„$SERVICENAME$“ klaida: $ERRORMESSAGE$.", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2288,11 +2288,11 @@ } }, "forwarderGeneratedBy": { - "message": "Generated by Bitwarden.", + "message": "Sugeneravo „Bitwarden“.", "description": "Displayed with the address on the forwarding service's configuration screen." }, "forwarderGeneratedByWithWebsite": { - "message": "Website: $WEBSITE$. Generated by Bitwarden.", + "message": "Svetainė: $WEBSITE$. Sugeneravo „Bitwarden“.", "description": "Displayed with the address on the forwarding service's configuration screen.", "placeholders": { "WEBSITE": { @@ -2302,7 +2302,7 @@ } }, "forwaderInvalidToken": { - "message": "Invalid $SERVICENAME$ API token", + "message": "Netinkamas „$SERVICENAME$“ API prieigos raktas.", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -2312,7 +2312,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Invalid $SERVICENAME$ API token: $ERRORMESSAGE$", + "message": "Netinkamas „$SERVICENAME$“ API prieigos raktas: $ERRORMESSAGE$.", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -2326,7 +2326,7 @@ } }, "forwarderNoAccountId": { - "message": "Unable to obtain $SERVICENAME$ masked email account ID.", + "message": "Nepavyksta gauti „$SERVICENAME$“ užmaskuoto el. pašto paskyros ID.", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -2336,7 +2336,7 @@ } }, "forwarderNoDomain": { - "message": "Invalid $SERVICENAME$ domain.", + "message": "Netinkamas „$SERVICENAME$“ domenas.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -2346,7 +2346,7 @@ } }, "forwarderNoUrl": { - "message": "Invalid $SERVICENAME$ url.", + "message": "Netinkamas „$SERVICENAME$“ URL.", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -2356,7 +2356,7 @@ } }, "forwarderUnknownError": { - "message": "Unknown $SERVICENAME$ error occurred.", + "message": "Įvyko nežinoma „$SERVICENAME$“ klaida.", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -2366,7 +2366,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Unknown forwarder: '$SERVICENAME$'.", + "message": "Nežinomas persiuntėjas: „$SERVICENAME$“.", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -3287,13 +3287,13 @@ "message": "Administratoriaus konsolės" }, "accountSecurity": { - "message": "Account security" + "message": "Paskyros saugumas" }, "notifications": { - "message": "Notifications" + "message": "Pranešimai" }, "appearance": { - "message": "Appearance" + "message": "Išvaizda" }, "errorAssigningTargetCollection": { "message": "Klaida priskiriant tikslinę kolekciją." diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 210f2358e0..deb7fda04b 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -763,7 +763,7 @@ "message": "Lås upp" }, "additionalOptions": { - "message": "Additional options" + "message": "Ytterligare alternativ" }, "enableContextMenuItem": { "message": "Visa alternativ för snabbmenyn" @@ -803,7 +803,7 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportFrom": { - "message": "Export from" + "message": "Exportera från" }, "exportVault": { "message": "Exportera valv" @@ -815,7 +815,7 @@ "message": "This file export will be password protected and require the file password to decrypt." }, "filePassword": { - "message": "File password" + "message": "Fillösenord" }, "exportPasswordDescription": { "message": "This password will be used to export and import this file" @@ -827,7 +827,7 @@ "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." }, "exportTypeHeading": { - "message": "Export type" + "message": "Exporttyp" }, "accountRestricted": { "message": "Account restricted" diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 571206dbb8..a97d9626a4 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -763,7 +763,7 @@ "message": "Розблокувати" }, "additionalOptions": { - "message": "Additional options" + "message": "Додаткові налаштування" }, "enableContextMenuItem": { "message": "Показувати в контекстному меню" @@ -803,7 +803,7 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportFrom": { - "message": "Export from" + "message": "Експортувати з" }, "exportVault": { "message": "Експортувати сховище" @@ -812,28 +812,28 @@ "message": "Формат файлу" }, "fileEncryptedExportWarningDesc": { - "message": "This file export will be password protected and require the file password to decrypt." + "message": "Цей експортований файл буде захищений паролем, який необхідно ввести для його розшифрування." }, "filePassword": { - "message": "File password" + "message": "Пароль файлу" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "Цей пароль буде використано для експортування та імпортування цього файлу" }, "accountRestrictedOptionDescription": { - "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + "message": "Використовуйте ключ шифрування свого облікового запису, створений на основі імені користувача й головного пароля, щоб зашифрувати експортовані дані та обмежити можливість імпортування лише до поточного облікового запису Bitwarden." }, "passwordProtectedOptionDescription": { - "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + "message": "Встановіть пароль файлу, щоб зашифрувати експортовані дані та імпортувати до будь-якого облікового запису Bitwarden за допомогою цього пароля." }, "exportTypeHeading": { - "message": "Export type" + "message": "Тип експорту" }, "accountRestricted": { - "message": "Account restricted" + "message": "Обмежено обліковим записом" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "“File password” and “Confirm file password“ do not match." + "message": "Пароль файлу та підтвердження пароля відрізняються." }, "warning": { "message": "ПОПЕРЕДЖЕННЯ", @@ -2213,10 +2213,10 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "Експортування сховища організації" }, "exportingOrganizationVaultDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Елементи особистих сховищ або інших організацій не будуть включені.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 7618173009..609275567b 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -821,10 +821,10 @@ "message": "此密码将用于导出和导入此文件" }, "accountRestrictedOptionDescription": { - "message": "使用衍生自您账户的用户名和主密码的加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。" + "message": "使用衍生自您账户的用户名和主密码的账户加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。" }, "passwordProtectedOptionDescription": { - "message": "设置一个密码用来加密导出的数据,并使用此密码解密以导入到任意 Bitwarden 账户。" + "message": "设置一个文件密码用来加密此导出,并使用此密码解密以导入到任意 Bitwarden 账户。" }, "exportTypeHeading": { "message": "导出类型" diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 274649ef13..63721466f6 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -9,6 +9,7 @@ import { AuthRequestService, LoginEmailServiceAbstraction, LoginEmailService, + LogoutReason, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -375,8 +376,17 @@ export default class MainBackground { } }; - const logoutCallback = async (expired: boolean, userId?: UserId) => - await this.logout(expired, userId); + const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) => + await this.logout(logoutReason, userId); + + const refreshAccessTokenErrorCallback = () => { + // Send toast to popup + this.messagingService.send("showToast", { + type: "error", + title: this.i18nService.t("errorRefreshingAccessToken"), + message: this.i18nService.t("errorRefreshingAccessTokenDesc"), + }); + }; const isDev = process.env.ENV === "development"; this.logService = new ConsoleLogService(isDev); @@ -523,6 +533,7 @@ export default class MainBackground { this.keyGenerationService, this.encryptService, this.logService, + logoutCallback, ); const migrationRunner = new MigrationRunner( @@ -608,9 +619,12 @@ export default class MainBackground { this.platformUtilsService, this.environmentService, this.appIdService, + refreshAccessTokenErrorCallback, + this.logService, + (logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId), this.vaultTimeoutSettingsService, - (expired: boolean) => this.logout(expired), ); + this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider); this.fileUploadService = new FileUploadService(this.logService); this.cipherFileUploadService = new CipherFileUploadService( @@ -1283,7 +1297,7 @@ export default class MainBackground { } } - async logout(expired: boolean, userId?: UserId) { + async logout(logoutReason: LogoutReason, userId?: UserId) { const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe( map((a) => a?.id), @@ -1349,7 +1363,7 @@ export default class MainBackground { await logoutPromise; this.messagingService.send("doneLoggingOut", { - expired: expired, + logoutReason: logoutReason, userId: userBeingLoggedOut, }); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index ab81bd7686..623e5d1b14 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.5.2", + "version": "2024.6.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index dc444f5465..7c848e21b5 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.5.2", + "version": "2024.6.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 7e94e84ef5..b70a5564ed 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angula import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -10,7 +11,12 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { MessageListener } from "@bitwarden/common/platform/messaging"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; +import { + DialogService, + SimpleDialogOptions, + ToastOptions, + ToastService, +} from "@bitwarden/components"; import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; @@ -83,13 +89,10 @@ export class AppComponent implements OnInit, OnDestroy { .pipe( tap((msg: any) => { if (msg.command === "doneLoggingOut") { + // TODO: PM-8544 - why do we call logout in the popup after receiving the doneLoggingOut message? Hasn't this already completeted logout? this.authService.logOut(async () => { - if (msg.expired) { - this.toastService.showToast({ - variant: "warning", - title: this.i18nService.t("loggedOut"), - message: this.i18nService.t("loginExpired"), - }); + if (msg.logoutReason) { + await this.displayLogoutReason(msg.logoutReason); } }); this.changeDetectorRef.detectChanges(); @@ -233,4 +236,23 @@ export class AppComponent implements OnInit, OnDestroy { this.browserSendStateService.setBrowserSendTypeComponentState(null), ]); } + + // Displaying toasts isn't super useful on the popup due to the reloads we do. + // However, it is visible for a moment on the FF sidebar logout. + private async displayLogoutReason(logoutReason: LogoutReason) { + let toastOptions: ToastOptions; + switch (logoutReason) { + case "invalidSecurityStamp": + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }; + break; + } + } + + this.toastService.showToast(toastOptions); + } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html new file mode 100644 index 0000000000..6136db59f4 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html @@ -0,0 +1,39 @@ +
+ + + + + + + + + + + + + + +
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts new file mode 100644 index 0000000000..886e1a966a --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.ts @@ -0,0 +1,28 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ChipSelectComponent } from "@bitwarden/components"; + +import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.service"; + +@Component({ + standalone: true, + selector: "app-vault-list-filters", + templateUrl: "./vault-list-filters.component.html", + imports: [CommonModule, JslibModule, ChipSelectComponent, ReactiveFormsModule], +}) +export class VaultListFiltersComponent implements OnDestroy { + protected filterForm = this.vaultPopupListFiltersService.filterForm; + protected organizations$ = this.vaultPopupListFiltersService.organizations$; + protected collections$ = this.vaultPopupListFiltersService.collections$; + protected folders$ = this.vaultPopupListFiltersService.folders$; + protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes; + + constructor(private vaultPopupListFiltersService: VaultPopupListFiltersService) {} + + ngOnDestroy(): void { + this.vaultPopupListFiltersService.resetFilterForm(); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index 7d83d9f26c..4d75685f53 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -22,13 +22,13 @@ - - + +
@@ -37,7 +37,17 @@
- +
+ + {{ "organizationIsDeactivated" | i18n }} + {{ "contactYourOrgAdmin" | i18n }} + +
+ + { let service: VaultPopupItemsService; @@ -20,6 +22,8 @@ describe("VaultPopupItemsService", () => { const cipherServiceMock = mock(); const vaultSettingsServiceMock = mock(); + const organizationServiceMock = mock(); + const vaultPopupListFiltersServiceMock = mock(); const searchService = mock(); beforeEach(() => { @@ -40,6 +44,18 @@ describe("VaultPopupItemsService", () => { cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers); vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable(); vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable(); + + vaultPopupListFiltersServiceMock.filters$ = new BehaviorSubject({ + organization: null, + collection: null, + cipherType: null, + folder: null, + }); + // Return all ciphers, `filterFunction$` will be tested in `VaultPopupListFiltersService` + vaultPopupListFiltersServiceMock.filterFunction$ = new BehaviorSubject( + (ciphers: CipherView[]) => ciphers, + ); + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); jest .spyOn(BrowserApi, "getTabFromCurrentWindow") @@ -47,6 +63,8 @@ describe("VaultPopupItemsService", () => { service = new VaultPopupItemsService( cipherServiceMock, vaultSettingsServiceMock, + vaultPopupListFiltersServiceMock, + organizationServiceMock, searchService, ); }); @@ -55,6 +73,8 @@ describe("VaultPopupItemsService", () => { service = new VaultPopupItemsService( cipherServiceMock, vaultSettingsServiceMock, + vaultPopupListFiltersServiceMock, + organizationServiceMock, searchService, ); expect(service).toBeTruthy(); @@ -87,6 +107,8 @@ describe("VaultPopupItemsService", () => { service = new VaultPopupItemsService( cipherServiceMock, vaultSettingsServiceMock, + vaultPopupListFiltersServiceMock, + organizationServiceMock, searchService, ); @@ -117,6 +139,8 @@ describe("VaultPopupItemsService", () => { service = new VaultPopupItemsService( cipherServiceMock, vaultSettingsServiceMock, + vaultPopupListFiltersServiceMock, + organizationServiceMock, searchService, ); @@ -228,6 +252,8 @@ describe("VaultPopupItemsService", () => { service = new VaultPopupItemsService( cipherServiceMock, vaultSettingsServiceMock, + vaultPopupListFiltersServiceMock, + organizationServiceMock, searchService, ); service.emptyVault$.subscribe((empty) => { diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 9a66ada08c..f9c37f6f7d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -2,6 +2,8 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, combineLatest, + distinctUntilKeyChanged, + from, map, Observable, of, @@ -12,6 +14,7 @@ import { } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -20,6 +23,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { BrowserApi } from "../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; +import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; + /** * Service for managing the various item lists on the new Vault tab in the browser popup. */ @@ -72,7 +77,15 @@ export class VaultPopupItemsService { shareReplay({ refCount: false, bufferSize: 1 }), ); - private _filteredCipherList$ = combineLatest([this._cipherList$, this.searchText$]).pipe( + private _filteredCipherList$: Observable = combineLatest([ + this._cipherList$, + this.searchText$, + this.vaultPopupListFiltersService.filterFunction$, + ]).pipe( + map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [ + filterFunction(ciphers), + searchText, + ]), switchMap(([ciphers, searchText]) => this.searchService.searchCiphers(searchText, null, ciphers), ), @@ -137,10 +150,19 @@ export class VaultPopupItemsService { /** * Observable that indicates whether a filter is currently applied to the ciphers. - * @todo Implement filter/search functionality in PM-6824 and PM-6826. */ - hasFilterApplied$: Observable = this.searchText$.pipe( - switchMap((text) => this.searchService.isSearchable(text)), + hasFilterApplied$ = combineLatest([ + this.searchText$, + this.vaultPopupListFiltersService.filters$, + ]).pipe( + switchMap(([searchText, filters]) => { + return from(this.searchService.isSearchable(searchText)).pipe( + map( + (isSearchable) => + isSearchable || Object.values(filters).some((filter) => filter !== null), + ), + ); + }), ); /** @@ -156,15 +178,31 @@ export class VaultPopupItemsService { /** * Observable that indicates whether there are no ciphers to show with the current filter. - * @todo Implement filter/search functionality in PM-6824 and PM-6826. */ noFilteredResults$: Observable = this._filteredCipherList$.pipe( map((ciphers) => !ciphers.length), ); + /** Observable that indicates when the user should see the deactivated org state */ + showDeactivatedOrg$: Observable = combineLatest([ + this.vaultPopupListFiltersService.filters$.pipe(distinctUntilKeyChanged("organization")), + this.organizationService.organizations$, + ]).pipe( + map(([filters, orgs]) => { + if (!filters.organization || filters.organization.id === MY_VAULT_ID) { + return false; + } + + const org = orgs.find((o) => o.id === filters.organization.id); + return org ? !org.enabled : false; + }), + ); + constructor( private cipherService: CipherService, private vaultSettingsService: VaultSettingsService, + private vaultPopupListFiltersService: VaultPopupListFiltersService, + private organizationService: OrganizationService, private searchService: SearchService, ) {} diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts new file mode 100644 index 0000000000..eba8f94f12 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -0,0 +1,298 @@ +import { TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { BehaviorSubject, skipWhile } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +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 { CipherType } from "@bitwarden/common/vault/enums"; +import { Collection } from "@bitwarden/common/vault/models/domain/collection"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; + +import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; + +describe("VaultPopupListFiltersService", () => { + let service: VaultPopupListFiltersService; + const memberOrganizations$ = new BehaviorSubject<{ name: string; id: string }[]>([]); + const folderViews$ = new BehaviorSubject([]); + const cipherViews$ = new BehaviorSubject({}); + const decryptedCollections$ = new BehaviorSubject([]); + + const collectionService = { + decryptedCollections$, + getAllNested: () => Promise.resolve([]), + } as unknown as CollectionService; + + const folderService = { + folderViews$, + } as unknown as FolderService; + + const cipherService = { + cipherViews$, + } as unknown as CipherService; + + const organizationService = { + memberOrganizations$, + } as unknown as OrganizationService; + + const i18nService = { + t: (key: string) => key, + } as I18nService; + + beforeEach(() => { + memberOrganizations$.next([]); + decryptedCollections$.next([]); + + collectionService.getAllNested = () => Promise.resolve([]); + TestBed.configureTestingModule({ + providers: [ + { + provide: FolderService, + useValue: folderService, + }, + { + provide: CipherService, + useValue: cipherService, + }, + { + provide: OrganizationService, + useValue: organizationService, + }, + { + provide: I18nService, + useValue: i18nService, + }, + { + provide: CollectionService, + useValue: collectionService, + }, + { provide: FormBuilder, useClass: FormBuilder }, + ], + }); + + service = TestBed.inject(VaultPopupListFiltersService); + }); + + describe("cipherTypes", () => { + it("returns all cipher types", () => { + expect(service.cipherTypes.map((c) => c.value)).toEqual([ + CipherType.Login, + CipherType.Card, + CipherType.Identity, + CipherType.SecureNote, + ]); + }); + }); + + describe("organizations$", () => { + it('does not add "myVault" to the list of organizations when there are no organizations', (done) => { + memberOrganizations$.next([]); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual([]); + done(); + }); + }); + + it('adds "myVault" to the list of organizations when there are other organizations', (done) => { + memberOrganizations$.next([{ name: "bobby's org", id: "1234-3323-23223" }]); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual(["myVault", "bobby's org"]); + done(); + }); + }); + + it("sorts organizations by name", (done) => { + memberOrganizations$.next([ + { name: "bobby's org", id: "1234-3323-23223" }, + { name: "alice's org", id: "2223-4343-99888" }, + ]); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual([ + "myVault", + "alice's org", + "bobby's org", + ]); + done(); + }); + }); + }); + + describe("collections$", () => { + const testCollection = { + id: "14cbf8e9-7a2a-4105-9bf6-b15c01203cef", + name: "Test collection", + organizationId: "3f860945-b237-40bc-a51e-b15c01203ccf", + } as CollectionView; + + const testCollection2 = { + id: "b15c0120-7a2a-4105-9bf6-b15c01203ceg", + name: "Test collection 2", + organizationId: "1203ccf-2432-123-acdd-b15c01203ccf", + } as CollectionView; + + const testCollections = [testCollection, testCollection2]; + + beforeEach(() => { + decryptedCollections$.next(testCollections); + + collectionService.getAllNested = () => + Promise.resolve( + testCollections.map((c) => ({ + children: [], + node: c, + parent: null, + })), + ); + }); + + it("returns all collections", (done) => { + service.collections$.subscribe((collections) => { + expect(collections.map((c) => c.label)).toEqual(["Test collection", "Test collection 2"]); + done(); + }); + }); + + it("filters out collections that do not belong to an organization", () => { + service.filterForm.patchValue({ + organization: { id: testCollection2.organizationId } as Organization, + }); + + service.collections$.subscribe((collections) => { + expect(collections.map((c) => c.label)).toEqual(["Test collection 2"]); + }); + }); + }); + + describe("folders$", () => { + it('returns no folders when "No Folder" is the only option', (done) => { + folderViews$.next([{ id: null, name: "No Folder" }]); + + service.folders$.subscribe((folders) => { + expect(folders).toEqual([]); + done(); + }); + }); + + it('moves "No Folder" to the end of the list', (done) => { + folderViews$.next([ + { id: null, name: "No Folder" }, + { id: "2345", name: "Folder 2" }, + { id: "1234", name: "Folder 1" }, + ]); + + service.folders$.subscribe((folders) => { + expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2", "itemsWithNoFolder"]); + done(); + }); + }); + + it("returns all folders when MyVault is selected", (done) => { + service.filterForm.patchValue({ + organization: { id: MY_VAULT_ID } as Organization, + }); + + folderViews$.next([ + { id: "1234", name: "Folder 1" }, + { id: "2345", name: "Folder 2" }, + ]); + + service.folders$.subscribe((folders) => { + expect(folders.map((f) => f.label)).toEqual(["Folder 1", "Folder 2"]); + done(); + }); + }); + + it("returns folders that have ciphers within the selected organization", (done) => { + service.folders$.pipe(skipWhile((folders) => folders.length === 2)).subscribe((folders) => { + expect(folders.map((f) => f.label)).toEqual(["Folder 1"]); + done(); + }); + + service.filterForm.patchValue({ + organization: { id: "1234" } as Organization, + }); + + folderViews$.next([ + { id: "1234", name: "Folder 1" }, + { id: "2345", name: "Folder 2" }, + ]); + + cipherViews$.next({ + "1": { folderId: "1234", organizationId: "1234" }, + "2": { folderId: "2345", organizationId: "56789" }, + }); + }); + }); + + describe("filterFunction$", () => { + const ciphers = [ + { type: CipherType.Login, collectionIds: [], organizationId: null }, + { type: CipherType.Card, collectionIds: ["1234"], organizationId: "8978" }, + { type: CipherType.Identity, collectionIds: [], folderId: "5432", organizationId: null }, + { type: CipherType.SecureNote, collectionIds: [], organizationId: null }, + ] as CipherView[]; + + it("filters by cipherType", (done) => { + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[0]]); + done(); + }); + + service.filterForm.patchValue({ cipherType: CipherType.Login }); + }); + + it("filters by collection", (done) => { + const collection = { id: "1234" } as Collection; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[1]]); + done(); + }); + + service.filterForm.patchValue({ collection }); + }); + + it("filters by folder", (done) => { + const folder = { id: "5432" } as FolderView; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[2]]); + done(); + }); + + service.filterForm.patchValue({ folder }); + }); + + describe("organizationId", () => { + it("filters out ciphers that belong to an organization when MyVault is selected", (done) => { + const organization = { id: MY_VAULT_ID } as Organization; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[0], ciphers[2], ciphers[3]]); + done(); + }); + + service.filterForm.patchValue({ organization }); + }); + + it("filters out ciphers that do not belong to the selected organization", (done) => { + const organization = { id: "8978" } as Organization; + + service.filterFunction$.subscribe((filterFunction) => { + expect(filterFunction(ciphers)).toEqual([ciphers[1]]); + done(); + }); + + service.filterForm.patchValue({ organization }); + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts new file mode 100644 index 0000000000..f3522aa8e3 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -0,0 +1,371 @@ +import { Injectable } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder } from "@angular/forms"; +import { + Observable, + combineLatest, + distinctUntilChanged, + map, + startWith, + switchMap, + tap, +} from "rxjs"; + +import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { CipherType } from "@bitwarden/common/vault/enums"; +import { Collection } from "@bitwarden/common/vault/models/domain/collection"; +import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { ChipSelectOption } from "@bitwarden/components"; + +/** All available cipher filters */ +export type PopupListFilter = { + organization: Organization | null; + collection: Collection | null; + folder: FolderView | null; + cipherType: CipherType | null; +}; + +/** Delimiter that denotes a level of nesting */ +const NESTING_DELIMITER = "/"; + +/** Id assigned to the "My vault" organization */ +export const MY_VAULT_ID = "MyVault"; + +const INITIAL_FILTERS: PopupListFilter = { + organization: null, + collection: null, + folder: null, + cipherType: null, +}; + +@Injectable({ + providedIn: "root", +}) +export class VaultPopupListFiltersService { + /** + * UI form for all filters + */ + filterForm = this.formBuilder.group(INITIAL_FILTERS); + + /** + * Observable for `filterForm` value + */ + filters$ = this.filterForm.valueChanges.pipe( + startWith(INITIAL_FILTERS), + ) as Observable; + + /** + * Static list of ciphers views used in synchronous context + */ + private cipherViews: CipherView[] = []; + + /** + * Observable of cipher views + */ + private cipherViews$: Observable = this.cipherService.cipherViews$.pipe( + tap((cipherViews) => { + this.cipherViews = Object.values(cipherViews); + }), + map((ciphers) => Object.values(ciphers)), + ); + + constructor( + private folderService: FolderService, + private cipherService: CipherService, + private organizationService: OrganizationService, + private i18nService: I18nService, + private collectionService: CollectionService, + private formBuilder: FormBuilder, + ) { + this.filterForm.controls.organization.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(this.validateOrganizationChange.bind(this)); + } + + /** + * Observable whose value is a function that filters an array of `CipherView` objects based on the current filters + */ + filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = this.filters$.pipe( + map( + (filters) => (ciphers: CipherView[]) => + ciphers.filter((cipher) => { + if (filters.cipherType !== null && cipher.type !== filters.cipherType) { + return false; + } + + if ( + filters.collection !== null && + !cipher.collectionIds.includes(filters.collection.id) + ) { + return false; + } + + if (filters.folder !== null && cipher.folderId !== filters.folder.id) { + return false; + } + + const isMyVault = filters.organization?.id === MY_VAULT_ID; + + if (isMyVault) { + if (cipher.organizationId !== null) { + return false; + } + } else if (filters.organization !== null) { + if (cipher.organizationId !== filters.organization.id) { + return false; + } + } + + return true; + }), + ), + ); + + /** + * All available cipher types + */ + readonly cipherTypes: ChipSelectOption[] = [ + { + value: CipherType.Login, + label: this.i18nService.t("logins"), + icon: "bwi-globe", + }, + { + value: CipherType.Card, + label: this.i18nService.t("cards"), + icon: "bwi-credit-card", + }, + { + value: CipherType.Identity, + label: this.i18nService.t("identities"), + icon: "bwi-id-card", + }, + { + value: CipherType.SecureNote, + label: this.i18nService.t("notes"), + icon: "bwi-sticky-note", + }, + ]; + + /** Resets `filterForm` to the original state */ + resetFilterForm(): void { + this.filterForm.reset(INITIAL_FILTERS); + } + + /** + * Organization array structured to be directly passed to `ChipSelectComponent` + */ + organizations$: Observable[]> = + this.organizationService.memberOrganizations$.pipe( + map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))), + map((orgs) => { + if (!orgs.length) { + return []; + } + + return [ + // When the user is a member of an organization, make the "My Vault" option available + { + value: { id: MY_VAULT_ID } as Organization, + label: this.i18nService.t("myVault"), + icon: "bwi-user", + }, + ...orgs.map((org) => { + let icon = "bwi-business"; + + if (!org.enabled) { + // Show a warning icon if the organization is deactivated + icon = "bwi-exclamation-triangle tw-text-danger"; + } else if (org.planProductType === ProductType.Families) { + // Show a family icon if the organization is a family org + icon = "bwi-family"; + } + + return { + value: org, + label: org.name, + icon, + }; + }), + ]; + }), + ); + + /** + * Folder array structured to be directly passed to `ChipSelectComponent` + */ + folders$: Observable[]> = combineLatest([ + this.filters$.pipe( + distinctUntilChanged( + (previousFilter, currentFilter) => + // Only update the collections when the organizationId filter changes + previousFilter.organization?.id === currentFilter.organization?.id, + ), + ), + this.folderService.folderViews$, + this.cipherViews$, + ]).pipe( + map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { + if (folders.length === 1 && folders[0].id === null) { + // Do not display folder selections when only the "no folder" option is available. + return [filters, [], cipherViews]; + } + + // Sort folders by alphabetic name + folders.sort(Utils.getSortFunction(this.i18nService, "name")); + let arrangedFolders = folders; + + const noFolder = folders.find((f) => f.id === null); + + if (noFolder) { + // Update `name` of the "no folder" option to "Items with no folder" + noFolder.name = this.i18nService.t("itemsWithNoFolder"); + + // Move the "no folder" option to the end of the list + arrangedFolders = [...folders.filter((f) => f.id !== null), noFolder]; + } + return [filters, arrangedFolders, cipherViews]; + }), + map(([filters, folders, cipherViews]) => { + const organizationId = filters.organization?.id ?? null; + + // When no org or "My vault" is selected, return all folders + if (organizationId === null || organizationId === MY_VAULT_ID) { + return folders; + } + + const orgCiphers = cipherViews.filter((c) => c.organizationId === organizationId); + + // Return only the folders that have ciphers within the filtered organization + return folders.filter((f) => orgCiphers.some((oc) => oc.folderId === f.id)); + }), + map((folders) => { + const nestedFolders = this.getAllFoldersNested(folders); + return new DynamicTreeNode({ + fullList: folders, + nestedList: nestedFolders, + }); + }), + map((folders) => folders.nestedList.map(this.convertToChipSelectOption.bind(this))), + ); + + /** + * Collection array structured to be directly passed to `ChipSelectComponent` + */ + collections$: Observable[]> = combineLatest([ + this.filters$.pipe( + distinctUntilChanged( + (previousFilter, currentFilter) => + // Only update the collections when the organizationId filter changes + previousFilter.organization?.id === currentFilter.organization?.id, + ), + ), + this.collectionService.decryptedCollections$, + ]).pipe( + map(([filters, allCollections]) => { + const organizationId = filters.organization?.id ?? null; + // When the organization filter is selected, filter out collections that do not belong to the selected organization + const collections = + organizationId === null + ? allCollections + : allCollections.filter((c) => c.organizationId === organizationId); + + return collections; + }), + switchMap(async (collections) => { + const nestedCollections = await this.collectionService.getAllNested(collections); + + return new DynamicTreeNode({ + fullList: collections, + nestedList: nestedCollections, + }); + }), + map((collections) => collections.nestedList.map(this.convertToChipSelectOption.bind(this))), + ); + + /** + * Converts the given item into the `ChipSelectOption` structure + */ + private convertToChipSelectOption( + item: TreeNode, + ): ChipSelectOption { + return { + value: item.node, + label: item.node.name, + icon: "bwi-folder", // Organization & Folder icons are the same + children: item.children + ? item.children.map(this.convertToChipSelectOption.bind(this)) + : undefined, + }; + } + + /** + * Returns a nested folder structure based on the input FolderView array + */ + private getAllFoldersNested(folders: FolderView[]): TreeNode[] { + const nodes: TreeNode[] = []; + + folders.forEach((f) => { + const folderCopy = new FolderView(); + folderCopy.id = f.id; + folderCopy.revisionDate = f.revisionDate; + + // Remove "/" from beginning and end of the folder name + // then split the folder name by the delimiter + const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NESTING_DELIMITER) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NESTING_DELIMITER); + }); + + return nodes; + } + + /** + * Validate collection & folder filters when the organization filter changes + */ + private validateOrganizationChange(organization: Organization | null): void { + if (!organization) { + return; + } + + const currentFilters = this.filterForm.getRawValue(); + + // When the organization filter changes and a collection is already selected, + // reset the collection filter if the collection does not belong to the new organization filter + if (currentFilters.collection && currentFilters.collection.organizationId !== organization.id) { + this.filterForm.get("collection").setValue(null); + } + + // When the organization filter changes and a folder is already selected, + // reset the folder filter if the folder does not belong to the new organization filter + if ( + currentFilters.folder && + currentFilters.folder.id !== null && + organization.id !== MY_VAULT_ID + ) { + // Get all ciphers that belong to the new organization + const orgCiphers = this.cipherViews.filter((c) => c.organizationId === organization.id); + + // Find any ciphers within the organization that belong to the current folder + const newOrgContainsFolder = orgCiphers.some( + (oc) => oc.folderId === currentFilters.folder.id, + ); + + // If the new organization does not contain the current folder, reset the folder filter + if (!newOrgContainsFolder) { + this.filterForm.get("folder").setValue(null); + } + } + } +} diff --git a/apps/cli/package.json b/apps/cli/package.json index d8ddde3d67..1ad09cc17a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2024.5.0", + "version": "2024.6.0", "keywords": [ "bitwarden", "password", diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index 4849aef151..c480d9d1af 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -6,6 +6,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ApiService } from "@bitwarden/common/services/api.service"; @@ -21,8 +22,10 @@ export class NodeApiService extends ApiService { platformUtilsService: PlatformUtilsService, environmentService: EnvironmentService, appIdService: AppIdService, + refreshAccessTokenErrorCallback: () => Promise, + logService: LogService, + logoutCallback: () => Promise, vaultTimeoutSettingsService: VaultTimeoutSettingsService, - logoutCallback: (expired: boolean) => Promise, customUserAgent: string = null, ) { super( @@ -30,8 +33,10 @@ export class NodeApiService extends ApiService { platformUtilsService, environmentService, appIdService, - vaultTimeoutSettingsService, + refreshAccessTokenErrorCallback, + logService, logoutCallback, + vaultTimeoutSettingsService, customUserAgent, ); } diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container.ts index 882791ef9c..53039e9147 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container.ts @@ -255,6 +255,8 @@ export class ServiceContainer { p = path.join(process.env.HOME, ".config/Bitwarden CLI"); } + const logoutCallback = async () => await this.logout(); + this.platformUtilsService = new CliPlatformUtilsService(ClientType.Cli, packageJson); this.logService = new ConsoleLogService( this.platformUtilsService.isDev(), @@ -337,6 +339,7 @@ export class ServiceContainer { this.keyGenerationService, this.encryptService, this.logService, + logoutCallback, ); const migrationRunner = new MigrationRunner( @@ -421,13 +424,19 @@ export class ServiceContainer { VaultTimeoutStringType.Never, // default vault timeout ); + const refreshAccessTokenErrorCallback = () => { + throw new Error("Refresh Access token error"); + }; + this.apiService = new NodeApiService( this.tokenService, this.platformUtilsService, this.environmentService, this.appIdService, + refreshAccessTokenErrorCallback, + this.logService, + logoutCallback, this.vaultTimeoutSettingsService, - async (expired: boolean) => await this.logout(), customUserAgent, ); @@ -485,7 +494,7 @@ export class ServiceContainer { this.logService, this.organizationService, this.keyGenerationService, - async (expired: boolean) => await this.logout(), + logoutCallback, this.stateProvider, ); @@ -660,7 +669,7 @@ export class ServiceContainer { this.sendApiService, this.userDecryptionOptionsService, this.avatarService, - async (expired: boolean) => await this.logout(), + logoutCallback, this.billingAccountProfileStateService, this.tokenService, this.authService, diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index b921cab37b..8617553b37 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.80" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "arboard" @@ -83,9 +83,9 @@ dependencies = [ [[package]] name = "base64" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 4b2bc2e905..cded3d57ef 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -14,9 +14,9 @@ manual_test = [] [dependencies] aes = "=0.8.4" -anyhow = "=1.0.80" +anyhow = "=1.0.86" arboard = { version = "=3.3.2", default-features = false, features = ["wayland-data-control"] } -base64 = "=0.22.0" +base64 = "=0.22.1" cbc = { version = "=0.1.2", features = ["alloc"] } napi = { version = "=2.16.0", features = ["async"] } napi-derive = "=2.16.0" diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 3e29d65816..ac12731398 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -18,7 +18,7 @@ "yargs": "17.7.2" }, "devDependencies": { - "@types/node": "18.19.29", + "@types/node": "20.14.1", "@types/node-ipc": "9.2.3", "typescript": "4.7.4" } @@ -98,9 +98,10 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" }, "node_modules/@types/node": { - "version": "18.19.29", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.29.tgz", - "integrity": "sha512-5pAX7ggTmWZdhUrhRWLPf+5oM7F80bcKVCBbr0zwEkTNzTJL2CWQjznpFgHYy6GrzkYi2Yjy7DHKoynFxqPV8g==", + "version": "20.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.1.tgz", + "integrity": "sha512-T2MzSGEu+ysB/FkWfqmhV3PLyQlowdptmmgD20C6QxsS8Fmv5SjpZ1ayXaEC0S21/h5UJ9iA6W/5vSNU5l00OA==", + "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index b538834ccb..0f92d5b0b3 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -23,7 +23,7 @@ "yargs": "17.7.2" }, "devDependencies": { - "@types/node": "18.19.29", + "@types/node": "20.14.1", "@types/node-ipc": "9.2.3", "typescript": "4.7.4" }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f84f3e5949..129e9c43f0 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": "2024.5.1", + "version": "2024.6.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 7feea649c3..561e9b2df9 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -1,3 +1,4 @@ +import { DialogRef } from "@angular/cdk/dialog"; import { Component, NgZone, @@ -13,6 +14,7 @@ import { filter, firstValueFrom, map, Subject, takeUntil, timeout } from "rxjs"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -48,7 +50,7 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogService, ToastOptions, ToastService } from "@bitwarden/components"; import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginApprovalComponent } from "../auth/login/login-approval.component"; @@ -108,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy { private idleTimer: number = null; private isIdle = false; private activeUserId: UserId = null; + private activeSimpleDialog: DialogRef = null; private destroy$ = new Subject(); @@ -207,7 +210,7 @@ export class AppComponent implements OnInit, OnDestroy { break; case "logout": this.loading = message.userId == null || message.userId === this.activeUserId; - await this.logOut(!!message.expired, message.userId); + await this.logOut(message.logoutReason, message.userId); this.loading = false; break; case "lockVault": @@ -545,9 +548,73 @@ export class AppComponent implements OnInit, OnDestroy { this.messagingService.send("updateAppMenu", { updateRequest: updateRequest }); } + private async displayLogoutReason(logoutReason: LogoutReason) { + let toastOptions: ToastOptions; + + switch (logoutReason) { + case "invalidSecurityStamp": + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }; + break; + } + // We don't expect these scenarios to be common, but we want the user to + // understand why they are being logged out before a process reload. + case "accessTokenUnableToBeDecrypted": { + // Don't create multiple dialogs if this fires multiple times + if (this.activeSimpleDialog) { + // Let the caller of this function listen for the dialog to close + return firstValueFrom(this.activeSimpleDialog.closed); + } + + this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({ + title: { key: "loggedOut" }, + content: { key: "accessTokenUnableToBeDecrypted" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + await firstValueFrom(this.activeSimpleDialog.closed); + this.activeSimpleDialog = null; + + break; + } + case "refreshTokenSecureStorageRetrievalFailure": { + // Don't create multiple dialogs if this fires multiple times + if (this.activeSimpleDialog) { + // Let the caller of this function listen for the dialog to close + return firstValueFrom(this.activeSimpleDialog.closed); + } + + this.activeSimpleDialog = this.dialogService.openSimpleDialogRef({ + title: { key: "loggedOut" }, + content: { key: "refreshTokenSecureStorageRetrievalFailure" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + await firstValueFrom(this.activeSimpleDialog.closed); + this.activeSimpleDialog = null; + + break; + } + } + + if (toastOptions) { + this.toastService.showToast(toastOptions); + } + } + // Even though the userId parameter is no longer optional doesn't mean a message couldn't be // passing null-ish values to us. - private async logOut(expired: boolean, userId: UserId) { + private async logOut(logoutReason: LogoutReason, userId: UserId) { + await this.displayLogoutReason(logoutReason); + const activeUserId = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); @@ -620,15 +687,7 @@ export class AppComponent implements OnInit, OnDestroy { // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up if (userBeingLoggedOut === activeUserId) { - this.authService.logOut(async () => { - if (expired) { - this.platformUtilsService.showToast( - "warning", - this.i18nService.t("loggedOut"), - this.i18nService.t("loginExpired"), - ); - } - }); + this.authService.logOut(async () => {}); } } @@ -710,7 +769,7 @@ export class AppComponent implements OnInit, OnDestroy { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises options[1] === "logOut" - ? this.logOut(false, userId as UserId) + ? this.logOut("vaultTimeout", userId as UserId) : await this.vaultTimeoutService.lock(userId); } } diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index e0bcf2df5d..0143e6c274 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -1300,7 +1300,7 @@ "description": "ex. Date this password was updated" }, "exportFrom": { - "message": "Export from" + "message": "Buradan xaricə köçür" }, "exportVault": { "message": "Anbarı xaricə köçür" @@ -1309,31 +1309,31 @@ "message": "Fayl formatı" }, "fileEncryptedExportWarningDesc": { - "message": "This file export will be password protected and require the file password to decrypt." + "message": "Bu faylın xaricə köçürülməsi, parolla qorunacaq və şifrəsini açmaq üçün fayl parolu tələb olunacaq." }, "filePassword": { - "message": "File password" + "message": "Fayl parolu" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "Bu parol, bu faylı daxilə və xaricə köçürmək üçün istifadə olunacaq" }, "accountRestrictedOptionDescription": { - "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + "message": "Xaricə köçürməni şifrələmək və daxilə köçürməni yalnız mövcud Bitwarden hesabı ilə məhdudlaşdırmaq üçün hesabınızın istifadəçi adı və Ana Parolundan əldə edilən hesab şifrələmə açarınızı istifadə edin." }, "passwordProtected": { - "message": "Password protected" + "message": "Parolla qorunan" }, "passwordProtectedOptionDescription": { - "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + "message": "Xaricə köçürməni şifrələmək üçün bir fayl parolu təyin edin və şifrəni açma parolunu istifadə edərək bunu istənilən Bitwarden hesabına köçürün." }, "exportTypeHeading": { - "message": "Export type" + "message": "Xaricə köçürmə növü" }, "accountRestricted": { - "message": "Account restricted" + "message": "Hesab məhdudlaşdırıldı" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "“File password” and “Confirm file password“ do not match." + "message": "\"Fayl parolu\" və \"Fayl parolunu təsdiqlə\" uyuşmur." }, "hCaptchaUrl": { "message": "hCaptcha ünvanı", @@ -2102,10 +2102,10 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "Təşkilat anbarını xaricə köçürmə" }, "exportingOrganizationVaultDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "message": "Yalnız $ORGANIZATION$ ilə əlaqələndirilmiş təşkilat anbarı ixrac ediləcək. Fərdi anbardakı və digər təşkilat elementlər daxil edilmir.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 46f2e5e7f6..e5abc44372 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -1318,7 +1318,7 @@ "message": "Dieses Passwort wird zum Exportieren und Importieren dieser Datei verwendet" }, "accountRestrictedOptionDescription": { - "message": "Verwende den Verschlüsselungscode deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken." + "message": "Verwende den Verschlüsselungsschlüssel deines Kontos, abgeleitet vom Benutzernamen und Master-Passwort, um den Export zu verschlüsseln und den Import auf das aktuelle Bitwarden-Konto zu beschränken." }, "passwordProtected": { "message": "Passwortgeschützt" diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8a91771da2..82d57c205d 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -695,6 +695,15 @@ "selfHostedEnvironmentFooter": { "message": "Specify the base URL of your on-premises hosted Bitwarden installation." }, + "selfHostedBaseUrlHint": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" + }, + "selfHostedCustomEnvHeader" :{ + "message": "For advanced configuration, you can specify the base URL of each service independently." + }, + "selfHostedEnvFormInvalid" :{ + "message": "You must add either the base Server URL or at least one custom environment." + }, "customEnvironment": { "message": "Custom environment" }, @@ -743,6 +752,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, @@ -1212,6 +1224,12 @@ } } }, + "errorRefreshingAccessToken":{ + "message": "Access Token Refresh Error" + }, + "errorRefreshingAccessTokenDesc":{ + "message": "No refresh token or API keys found. Please try logging out and logging back in." + }, "help": { "message": "Help" }, @@ -2474,6 +2492,12 @@ "important": { "message": "Important:" }, + "accessTokenUnableToBeDecrypted": { + "message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue." + }, + "refreshTokenSecureStorageRetrievalFailure": { + "message": "You have been logged out because your refresh token could not be retrieved. Please log in again to resolve this issue." + }, "masterPasswordHint": { "message": "Your master password cannot be recovered if you forget it!" }, diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index e7dddd64aa..9bae4e883d 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -1300,7 +1300,7 @@ "description": "ex. Date this password was updated" }, "exportFrom": { - "message": "Export from" + "message": "Exportera från" }, "exportVault": { "message": "Exportera valv" diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index dc5672ed69..546005db20 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -1300,7 +1300,7 @@ "description": "ex. Date this password was updated" }, "exportFrom": { - "message": "Export from" + "message": "Експортувати з" }, "exportVault": { "message": "Експортувати сховище" @@ -1309,31 +1309,31 @@ "message": "Формат файлу" }, "fileEncryptedExportWarningDesc": { - "message": "This file export will be password protected and require the file password to decrypt." + "message": "Цей експортований файл буде захищений паролем, який необхідно ввести для його розшифрування." }, "filePassword": { - "message": "File password" + "message": "Пароль файлу" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "Цей пароль буде використано для експортування та імпортування цього файлу" }, "accountRestrictedOptionDescription": { - "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + "message": "Використовуйте ключ шифрування свого облікового запису, створений на основі імені користувача й головного пароля, щоб зашифрувати експортовані дані та обмежити можливість імпортування лише до поточного облікового запису Bitwarden." }, "passwordProtected": { - "message": "Password protected" + "message": "Захищено паролем" }, "passwordProtectedOptionDescription": { - "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + "message": "Встановіть пароль файлу, щоб зашифрувати експортовані дані та імпортувати до будь-якого облікового запису Bitwarden за допомогою цього пароля." }, "exportTypeHeading": { - "message": "Export type" + "message": "Тип експорту" }, "accountRestricted": { - "message": "Account restricted" + "message": "Обмежено обліковим записом" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "“File password” and “Confirm file password“ do not match." + "message": "Пароль файлу та підтвердження пароля відрізняються." }, "hCaptchaUrl": { "message": "URL-адреса hCaptcha", @@ -2102,10 +2102,10 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "Експортування сховища організації" }, "exportingOrganizationVaultDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Елементи особистих сховищ або інших організацій не будуть включені.", "placeholders": { "organization": { "content": "$1", diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 27686559fd..22b96d6e4b 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1300,7 +1300,7 @@ "description": "ex. Date this password was updated" }, "exportFrom": { - "message": "Export from" + "message": "导出自" }, "exportVault": { "message": "导出密码库" @@ -1309,31 +1309,31 @@ "message": "文件格式" }, "fileEncryptedExportWarningDesc": { - "message": "This file export will be password protected and require the file password to decrypt." + "message": "此文件导出将受密码保护,需要文件密码才能解密。" }, "filePassword": { - "message": "File password" + "message": "文件密码" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "此密码将用于导出和导入此文件" }, "accountRestrictedOptionDescription": { - "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." + "message": "使用衍生自您账户的用户名和主密码的账户加密密钥,以加密此导出并限制只能导入到当前的 Bitwarden 账户。" }, "passwordProtected": { - "message": "Password protected" + "message": "密码保护" }, "passwordProtectedOptionDescription": { - "message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption." + "message": "设置一个文件密码用来加密此导出,并使用此密码解密以导入到任意 Bitwarden 账户。" }, "exportTypeHeading": { - "message": "Export type" + "message": "导出类型" }, "accountRestricted": { - "message": "Account restricted" + "message": "账户受限" }, "filePasswordAndConfirmFilePasswordDoNotMatch": { - "message": "“File password” and “Confirm file password“ do not match." + "message": "「文件密码」与「确认文件密码」不一致。" }, "hCaptchaUrl": { "message": "hCaptcha URL", @@ -2102,10 +2102,10 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "正在导出组织密码库" }, "exportingOrganizationVaultDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "message": "仅会导出与 $ORGANIZATION$ 关联的组织密码库数据。不包括个人密码库和其他组织中的项目。", "placeholders": { "organization": { "content": "$1", diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d30d6ad821..59a306189a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,6 +3,7 @@ import * as path from "path"; import { app } from "electron"; import { Subject, firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -31,6 +32,7 @@ import { DefaultSingleUserStateProvider } from "@bitwarden/common/platform/state import { DefaultStateProvider } from "@bitwarden/common/platform/state/implementations/default-state.provider"; import { StateEventRegistrarService } from "@bitwarden/common/platform/state/state-event-registrar.service"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; +import { UserId } from "@bitwarden/common/types/guid"; /* eslint-enable import/no-restricted-paths */ import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service"; @@ -182,6 +184,7 @@ export class Main { this.keyGenerationService, this.encryptService, this.logService, + async (logoutReason: LogoutReason, userId?: UserId) => {}, ); this.migrationRunner = new MigrationRunner( @@ -207,11 +210,9 @@ export class Main { ); this.desktopSettingsService = new DesktopSettingsService(stateProvider); - const biometricStateService = new DefaultBiometricStateService(stateProvider); this.windowMain = new WindowMain( - this.stateService, biometricStateService, this.logService, this.storageService, diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 64b4bc48d2..e82d16ee9f 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -6,7 +6,6 @@ import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "elect import { firstValueFrom } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; @@ -38,7 +37,6 @@ export class WindowMain { readonly defaultHeight = 600; constructor( - private stateService: StateService, private biometricStateService: BiometricStateService, private logService: LogService, private storageService: AbstractStorageService, diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 39899a1e6b..34a4dc99f6 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.5.1", + "version": "2024.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.5.1", + "version": "2024.6.0", "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 9efd5bad9f..3a629f37cb 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": "2024.5.1", + "version": "2024.6.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/package.json b/apps/web/package.json index 6e5355c708..286811dd5c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.5.0", + "version": "2024.6.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index d1a48a78e1..237e2c6e30 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -52,7 +52,7 @@ *ngIf="canShowBillingTab(organization)" > - + diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts index 47ca0998bb..4383656bee 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, RouterModule } from "@angular/router"; -import { map, mergeMap, Observable, Subject, takeUntil } from "rxjs"; +import { combineLatest, map, mergeMap, Observable, Subject, switchMap, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -16,7 +16,8 @@ import { OrganizationService, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { PolicyType, ProviderStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -55,9 +56,14 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { organization$: Observable; showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; + organizationIsUnmanaged$: Observable; private _destroy = new Subject(); + protected consolidatedBillingEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + ); + protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$( FeatureFlag.ShowPaymentMethodWarningBanners, ); @@ -68,6 +74,7 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private configService: ConfigService, private policyService: PolicyService, + private providerService: ProviderService, ) {} async ngOnInit() { @@ -94,6 +101,24 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { ); this.hideNewOrgButton$ = this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg); + + const provider$ = this.organization$.pipe( + switchMap((organization) => this.providerService.get$(organization.providerId)), + ); + + this.organizationIsUnmanaged$ = combineLatest([ + this.consolidatedBillingEnabled$, + this.organization$, + provider$, + ]).pipe( + map( + ([consolidatedBillingEnabled, organization, provider]) => + !consolidatedBillingEnabled || + !organization.hasProvider || + !provider || + provider.providerStatus !== ProviderStatusType.Billable, + ), + ); } ngOnDestroy() { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 6c71309243..254f23eeb2 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -14,6 +14,7 @@ import { timer, } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -40,7 +41,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogService, ToastOptions, ToastService } from "@bitwarden/components"; import { PolicyListService } from "./admin-console/core/policy-list.service"; import { @@ -148,7 +149,7 @@ export class AppComponent implements OnDestroy, OnInit { this.router.navigate(["/"]); break; case "logout": - await this.logOut(!!message.expired, message.redirect); + await this.logOut(message.logoutReason, message.redirect); break; case "lockVault": await this.vaultTimeoutService.lock(); @@ -278,7 +279,34 @@ export class AppComponent implements OnDestroy, OnInit { this.destroy$.complete(); } - private async logOut(expired: boolean, redirect = true) { + private async displayLogoutReason(logoutReason: LogoutReason) { + let toastOptions: ToastOptions; + switch (logoutReason) { + case "invalidSecurityStamp": + case "sessionExpired": { + toastOptions = { + variant: "warning", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loginExpired"), + }; + break; + } + default: { + toastOptions = { + variant: "info", + title: this.i18nService.t("loggedOut"), + message: this.i18nService.t("loggedOutDesc"), + }; + break; + } + } + + this.toastService.showToast(toastOptions); + } + + private async logOut(logoutReason: LogoutReason, redirect = true) { + await this.displayLogoutReason(logoutReason); + await this.eventUploadService.uploadEvents(); const userId = (await this.stateService.getUserId()) as UserId; @@ -308,14 +336,6 @@ export class AppComponent implements OnDestroy, OnInit { await this.searchService.clearIndex(); this.authService.logOut(async () => { - if (expired) { - this.platformUtilsService.showToast( - "warning", - this.i18nService.t("loggedOut"), - this.i18nService.t("loginExpired"), - ); - } - await this.stateService.clean({ userId: userId }); await this.accountService.clean(userId); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts index ef3d657f2f..f7c391b0ee 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts @@ -20,8 +20,8 @@ export class WebauthnLoginAttestationResponseRequest extends WebauthnLoginAuthen } this.response = { - attestationObject: Utils.fromBufferToB64(credential.response.attestationObject), - clientDataJson: Utils.fromBufferToB64(credential.response.clientDataJSON), + attestationObject: Utils.fromBufferToUrlB64(credential.response.attestationObject), + clientDataJson: Utils.fromBufferToUrlB64(credential.response.clientDataJSON), }; } } diff --git a/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html b/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html index 4abb44db4f..f57fb7a351 100644 --- a/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/enterprise-content.component.html @@ -1,8 +1,8 @@ -

Start your 7-day free trial of Bitwarden

+

Start your 7-day Enterprise free trial

- Strengthen business security with the password manager designed for seamless administration and - employee usability. + Bitwarden is the most trusted password manager designed for seamless administration and employee + usability.

    @@ -15,14 +15,14 @@
  • Strengthen employee security practices through centralized administrative control and + >Strengthen company-wide security through centralized administrative control and policies
  • Streamline user onboarding and automate account provisioning with turnkey SSO and SCIM + >Streamline user onboarding and automate account provisioning with flexible SSO and SCIM integrations
  • @@ -35,14 +35,7 @@
  • Save time and increase productivity with autofill and instant device syncing -
  • -
  • - Empower employees to secure their digital life at home, at work, and on the go by offering a - free Families plan to all Enterprise usersGive all Enterprise users the gift of 360º security with a free Families plan
diff --git a/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html b/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html index 120748d4c0..f57fb7a351 100644 --- a/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/enterprise1-content.component.html @@ -1,34 +1,44 @@ -

The Password Manager Trusted by Millions

-
-

Everything enterprises need out of a password manager:

+

Start your 7-day Enterprise free trial

+
+

+ Bitwarden is the most trusted password manager designed for seamless administration and employee + usability. +

    -
  • Secure password sharing
  • -
  • - Easy, flexible SSO and SCIM integrations +
  • + Instantly and securely share credentials with the groups and individuals who need them +
  • +
  • + Strengthen company-wide security through centralized administrative control and + policies +
  • +
  • + Streamline user onboarding and automate account provisioning with flexible SSO and SCIM + integrations +
  • +
  • + Migrate to Bitwarden in minutes with comprehensive import options +
  • +
  • + Give all Enterprise users the gift of 360º security with a free Families plan
  • -
  • Free families plan for users
  • -
  • Quick import and migration tools
  • -
  • Simple, streamlined user experience
  • -
  • Priority support and trainers
- -
- - - -
+
diff --git a/apps/web/src/app/auth/trial-initiation/content/enterprise2-content.component.html b/apps/web/src/app/auth/trial-initiation/content/enterprise2-content.component.html index 120748d4c0..f57fb7a351 100644 --- a/apps/web/src/app/auth/trial-initiation/content/enterprise2-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/enterprise2-content.component.html @@ -1,34 +1,44 @@ -

The Password Manager Trusted by Millions

-
-

Everything enterprises need out of a password manager:

+

Start your 7-day Enterprise free trial

+
+

+ Bitwarden is the most trusted password manager designed for seamless administration and employee + usability. +

    -
  • Secure password sharing
  • -
  • - Easy, flexible SSO and SCIM integrations +
  • + Instantly and securely share credentials with the groups and individuals who need them +
  • +
  • + Strengthen company-wide security through centralized administrative control and + policies +
  • +
  • + Streamline user onboarding and automate account provisioning with flexible SSO and SCIM + integrations +
  • +
  • + Migrate to Bitwarden in minutes with comprehensive import options +
  • +
  • + Give all Enterprise users the gift of 360º security with a free Families plan
  • -
  • Free families plan for users
  • -
  • Quick import and migration tools
  • -
  • Simple, streamlined user experience
  • -
  • Priority support and trainers
- -
- - - -
+
diff --git a/apps/web/src/app/auth/trial-initiation/content/teams1-content.component.html b/apps/web/src/app/auth/trial-initiation/content/teams1-content.component.html index 42f99be26b..f51c370beb 100644 --- a/apps/web/src/app/auth/trial-initiation/content/teams1-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/teams1-content.component.html @@ -1,6 +1,5 @@ -

Start your 7-day free trial for Teams

-
-
+

Start your 7-day free trial for Teams

+

Strengthen business security with an easy-to-use password manager your team will love.

diff --git a/apps/web/src/app/auth/trial-initiation/content/teams2-content.component.html b/apps/web/src/app/auth/trial-initiation/content/teams2-content.component.html index 3145e20d4f..f51c370beb 100644 --- a/apps/web/src/app/auth/trial-initiation/content/teams2-content.component.html +++ b/apps/web/src/app/auth/trial-initiation/content/teams2-content.component.html @@ -1,17 +1,35 @@ -

Start Your Free Trial Now

-
+

Start your 7-day free trial for Teams

+

- Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password - storage and sharing. + Strengthen business security with an easy-to-use password manager your team will love.

-
    -
  • Collaborate and share securely
  • -
  • Deploy and manage quickly and easily
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • +
      +
    • + Instantly and securely share credentials with the groups and individuals who need them +
    • +
    • + Migrate to Bitwarden in minutes with comprehensive import options +
    • +
    • + Save time and increase productivity with autofill and instant device syncing +
    • +
    • + Enhance security practices across your team with easy user management +
    - - +
    diff --git a/apps/web/src/app/billing/guards/organization-is-unmanaged.guard.ts b/apps/web/src/app/billing/guards/organization-is-unmanaged.guard.ts new file mode 100644 index 0000000000..a915d8f8a6 --- /dev/null +++ b/apps/web/src/app/billing/guards/organization-is-unmanaged.guard.ts @@ -0,0 +1,36 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn } from "@angular/router"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderStatusType } from "@bitwarden/common/admin-console/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +export const organizationIsUnmanaged: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const configService = inject(ConfigService); + const organizationService = inject(OrganizationService); + const providerService = inject(ProviderService); + + const consolidatedBillingEnabled = await configService.getFeatureFlag( + FeatureFlag.EnableConsolidatedBilling, + ); + + if (!consolidatedBillingEnabled) { + return true; + } + + const organization = await organizationService.get(route.params.organizationId); + + if (!organization.hasProvider) { + return true; + } + + const provider = await providerService.get(organization.providerId); + + if (!provider) { + return true; + } + + return provider.providerStatus !== ProviderStatusType.Billable; +}; diff --git a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts index 8ca7226b97..4af0662875 100644 --- a/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing-routing.module.ts @@ -5,6 +5,7 @@ import { canAccessBillingTab } from "@bitwarden/common/admin-console/abstraction import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationPermissionsGuard } from "../../admin-console/organizations/guards/org-permissions.guard"; +import { organizationIsUnmanaged } from "../../billing/guards/organization-is-unmanaged.guard"; import { WebPlatformUtilsService } from "../../core/web-platform-utils.service"; import { PaymentMethodComponent } from "../shared"; @@ -29,7 +30,7 @@ const routes: Routes = [ { path: "payment-method", component: PaymentMethodComponent, - canActivate: [OrganizationPermissionsGuard], + canActivate: [OrganizationPermissionsGuard, organizationIsUnmanaged], data: { titleId: "paymentMethod", organizationPermissions: (org: Organization) => org.canEditPaymentMethods, @@ -38,7 +39,7 @@ const routes: Routes = [ { path: "history", component: OrgBillingHistoryViewComponent, - canActivate: [OrganizationPermissionsGuard], + canActivate: [OrganizationPermissionsGuard, organizationIsUnmanaged], data: { titleId: "billingHistory", organizationPermissions: (org: Organization) => org.canViewBillingHistory, diff --git a/apps/web/src/images/register-layout/vault-signup-badges.png b/apps/web/src/images/register-layout/vault-signup-badges.png index 7a80ffaebb..c8a7ae2f48 100644 Binary files a/apps/web/src/images/register-layout/vault-signup-badges.png and b/apps/web/src/images/register-layout/vault-signup-badges.png differ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c8f1931859..d7a21ad6d6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -587,6 +587,9 @@ "loggedOut": { "message": "Logged out" }, + "loggedOutDesc": { + "message": "You have been logged out of your account." + }, "loginExpired": { "message": "Your login session has expired." }, @@ -1050,6 +1053,12 @@ "copyUuid": { "message": "Copy UUID" }, + "errorRefreshingAccessToken":{ + "message": "Access Token Refresh Error" + }, + "errorRefreshingAccessTokenDesc":{ + "message": "No refresh token or API keys found. Please try logging out and logging back in." + }, "warning": { "message": "Warning" }, @@ -5586,6 +5595,39 @@ "rotateBillingSyncTokenTitle": { "message": "Rotating the billing sync token will invalidate the previous token." }, + "selfHostedServer": { + "message": "self-hosted" + }, + "customEnvironment": { + "message": "Custom environment" + }, + "selfHostedBaseUrlHint": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation. Example: https://bitwarden.company.com" + }, + "selfHostedCustomEnvHeader" :{ + "message": "For advanced configuration, you can specify the base URL of each service independently." + }, + "selfHostedEnvFormInvalid" :{ + "message": "You must add either the base Server URL or at least one custom environment." + }, + "apiUrl": { + "message": "API server URL" + }, + "webVaultUrl": { + "message": "Web vault server URL" + }, + "identityUrl": { + "message": "Identity server URL" + }, + "notificationsUrl": { + "message": "Notifications server URL" + }, + "iconsUrl": { + "message": "Icons server URL" + }, + "environmentSaved": { + "message": "Environment URLs saved" + }, "selfHostingTitle": { "message": "Self-hosting" }, @@ -8297,5 +8339,20 @@ }, "allLoginRequestsApproved": { "message": "All login requests approved" + }, + "payPal": { + "message": "PayPal" + }, + "bitcoin": { + "message": "Bitcoin" + }, + "updatedTaxInformation": { + "message": "Updated tax information" + }, + "unverified": { + "message": "Unverified" + }, + "verified": { + "message": "Verified" } } diff --git a/apps/web/src/locales/zh_CN/messages.json b/apps/web/src/locales/zh_CN/messages.json index 7196fa26e3..652a1f3d67 100644 --- a/apps/web/src/locales/zh_CN/messages.json +++ b/apps/web/src/locales/zh_CN/messages.json @@ -2105,7 +2105,7 @@ "message": "Bitwarden 家庭版计划。" }, "addons": { - "message": "附加项目" + "message": "插件" }, "premiumAccess": { "message": "高级会员" diff --git a/babel.config.json b/babel.config.json index 7f4611dec0..4d817f0abf 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,4 +1,11 @@ { - "presets": ["@babel/preset-env"], + "presets": [ + [ + "@babel/preset-env", + { + "bugfixes": true + } + ] + ], "plugins": ["@angular/compiler-cli/linker/babel"] } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts index a3a6c4943f..3214a0fc41 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve-all.command.ts @@ -1,9 +1,52 @@ +import { firstValueFrom } from "rxjs"; + +import { OrganizationAuthRequestService } from "@bitwarden/bit-common/admin-console/auth-requests"; import { Response } from "@bitwarden/cli/models/response"; +import { MessageResponse } from "@bitwarden/cli/models/response/message.response"; +import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; export class ApproveAllCommand { - constructor() {} + constructor( + private organizationAuthRequestService: OrganizationAuthRequestService, + private organizationService: OrganizationService, + ) {} async run(organizationId: string): Promise { - throw new Error("Not implemented"); + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const pendingApprovals = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + if (pendingApprovals.length == 0) { + const res = new MessageResponse( + "No pending device authorization requests to approve.", + null, + ); + return Response.success(res); + } + + await this.organizationAuthRequestService.approvePendingRequests( + organizationId, + pendingApprovals, + ); + + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts index b3a30165ce..8efa172296 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/approve.command.ts @@ -1,9 +1,54 @@ +import { firstValueFrom } from "rxjs"; + import { Response } from "@bitwarden/cli/models/response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests"; export class ApproveCommand { - constructor() {} + constructor( + private organizationService: OrganizationService, + private organizationAuthRequestService: OrganizationAuthRequestService, + ) {} - async run(id: string): Promise { - throw new Error("Not implemented"); + async run(organizationId: string, id: string): Promise { + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + if (id != null) { + id = id.toLowerCase(); + } + + if (!Utils.isGuid(id)) { + return Response.badRequest("`" + id + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const pendingRequests = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + + const request = pendingRequests.find((r) => r.id == id); + if (request == null) { + return Response.error("Invalid request id"); + } + + await this.organizationAuthRequestService.approvePendingRequest(organizationId, request); + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts index 521a7e8ded..59cc4235eb 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny-all.command.ts @@ -1,9 +1,49 @@ +import { firstValueFrom } from "rxjs"; + import { Response } from "@bitwarden/cli/models/response"; +import { MessageResponse } from "@bitwarden/cli/models/response/message.response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests"; export class DenyAllCommand { - constructor() {} + constructor( + private organizationService: OrganizationService, + private organizationAuthRequestService: OrganizationAuthRequestService, + ) {} async run(organizationId: string): Promise { - throw new Error("Not implemented"); + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const pendingRequests = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + if (pendingRequests.length == 0) { + const res = new MessageResponse("No pending device authorization requests to deny.", null); + return Response.success(res); + } + + await this.organizationAuthRequestService.denyPendingRequests( + organizationId, + ...pendingRequests.map((r) => r.id), + ); + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts index a366bfb05a..a9676d3fc5 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/deny.command.ts @@ -1,9 +1,46 @@ +import { firstValueFrom } from "rxjs"; + import { Response } from "@bitwarden/cli/models/response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { OrganizationAuthRequestService } from "../../../../bit-common/src/admin-console/auth-requests"; export class DenyCommand { - constructor() {} + constructor( + private organizationService: OrganizationService, + private organizationAuthRequestService: OrganizationAuthRequestService, + ) {} - async run(id: string): Promise { - throw new Error("Not implemented"); + async run(organizationId: string, id: string): Promise { + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + if (id != null) { + id = id.toLowerCase(); + } + + if (!Utils.isGuid(id)) { + return Response.badRequest("`" + id + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + await this.organizationAuthRequestService.denyPendingRequests(organizationId, id); + return Response.success(); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts index 152dd48c7b..0b0f3bb0f9 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts @@ -3,6 +3,8 @@ import { program, Command } from "commander"; import { BaseProgram } from "@bitwarden/cli/base-program"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ServiceContainer } from "../../service-container"; + import { ApproveAllCommand } from "./approve-all.command"; import { ApproveCommand } from "./approve.command"; import { DenyAllCommand } from "./deny-all.command"; @@ -10,6 +12,10 @@ import { DenyCommand } from "./deny.command"; import { ListCommand } from "./list.command"; export class DeviceApprovalProgram extends BaseProgram { + constructor(protected serviceContainer: ServiceContainer) { + super(serviceContainer); + } + register() { program.addCommand(this.deviceApprovalCommand()); } @@ -32,7 +38,10 @@ export class DeviceApprovalProgram extends BaseProgram { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new ListCommand(); + const cmd = new ListCommand( + this.serviceContainer.organizationAuthRequestService, + this.serviceContainer.organizationService, + ); const response = await cmd.run(organizationId); this.processResponse(response); }); @@ -40,27 +49,34 @@ export class DeviceApprovalProgram extends BaseProgram { private approveCommand(): Command { return new Command("approve") - .argument("") + .argument("", "The id of the organization") + .argument("", "The id of the request to approve") .description("Approve a pending request") - .action(async (id: string) => { + .action(async (organizationId: string, id: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new ApproveCommand(); - const response = await cmd.run(id); + const cmd = new ApproveCommand( + this.serviceContainer.organizationService, + this.serviceContainer.organizationAuthRequestService, + ); + const response = await cmd.run(organizationId, id); this.processResponse(response); }); } private approveAllCommand(): Command { - return new Command("approveAll") + return new Command("approve-all") .description("Approve all pending requests for an organization") .argument("") .action(async (organizationId: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new ApproveAllCommand(); + const cmd = new ApproveAllCommand( + this.serviceContainer.organizationAuthRequestService, + this.serviceContainer.organizationService, + ); const response = await cmd.run(organizationId); this.processResponse(response); }); @@ -68,27 +84,34 @@ export class DeviceApprovalProgram extends BaseProgram { private denyCommand(): Command { return new Command("deny") - .argument("") + .argument("", "The id of the organization") + .argument("", "The id of the request to deny") .description("Deny a pending request") - .action(async (id: string) => { + .action(async (organizationId: string, id: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new DenyCommand(); - const response = await cmd.run(id); + const cmd = new DenyCommand( + this.serviceContainer.organizationService, + this.serviceContainer.organizationAuthRequestService, + ); + const response = await cmd.run(organizationId, id); this.processResponse(response); }); } private denyAllCommand(): Command { - return new Command("denyAll") + return new Command("deny-all") .description("Deny all pending requests for an organization") .argument("") .action(async (organizationId: string) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); - const cmd = new DenyAllCommand(); + const cmd = new DenyAllCommand( + this.serviceContainer.organizationService, + this.serviceContainer.organizationAuthRequestService, + ); const response = await cmd.run(organizationId); this.processResponse(response); }); diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts index 11fb6ec3ee..10da11b35c 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/list.command.ts @@ -1,9 +1,42 @@ +import { firstValueFrom } from "rxjs"; + +import { OrganizationAuthRequestService } from "@bitwarden/bit-common/admin-console/auth-requests"; import { Response } from "@bitwarden/cli/models/response"; +import { ListResponse } from "@bitwarden/cli/models/response/list.response"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { PendingAuthRequestResponse } from "./pending-auth-request.response"; export class ListCommand { - constructor() {} + constructor( + private organizationAuthRequestService: OrganizationAuthRequestService, + private organizationService: OrganizationService, + ) {} async run(organizationId: string): Promise { - throw new Error("Not implemented"); + if (organizationId != null) { + organizationId = organizationId.toLowerCase(); + } + + if (!Utils.isGuid(organizationId)) { + return Response.badRequest("`" + organizationId + "` is not a GUID."); + } + + const organization = await firstValueFrom(this.organizationService.get$(organizationId)); + if (!organization?.canManageUsersPassword) { + return Response.error( + "You do not have permission to approve pending device authorization requests.", + ); + } + + try { + const requests = + await this.organizationAuthRequestService.listPendingRequests(organizationId); + const res = new ListResponse(requests.map((r) => new PendingAuthRequestResponse(r))); + return Response.success(res); + } catch (e) { + return Response.error(e); + } } } diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/pending-auth-request.response.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/pending-auth-request.response.ts new file mode 100644 index 0000000000..991b3fb8e5 --- /dev/null +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/pending-auth-request.response.ts @@ -0,0 +1,26 @@ +import { PendingAuthRequestView } from "@bitwarden/bit-common/admin-console/auth-requests/"; +import { BaseResponse } from "@bitwarden/cli/models/response/base.response"; + +export class PendingAuthRequestResponse implements BaseResponse { + object = "auth-request"; + + id: string; + userId: string; + organizationUserId: string; + email: string; + requestDeviceIdentifier: string; + requestDeviceType: string; + requestIpAddress: string; + creationDate: Date; + + constructor(authRequest: PendingAuthRequestView) { + this.id = authRequest.id; + this.userId = authRequest.userId; + this.organizationUserId = authRequest.organizationUserId; + this.email = authRequest.email; + this.requestDeviceIdentifier = authRequest.requestDeviceIdentifier; + this.requestDeviceType = authRequest.requestDeviceType; + this.requestIpAddress = authRequest.requestIpAddress; + this.creationDate = authRequest.creationDate; + } +} diff --git a/bitwarden_license/bit-cli/src/service-container.ts b/bitwarden_license/bit-cli/src/service-container.ts index 369d54113d..995e14531d 100644 --- a/bitwarden_license/bit-cli/src/service-container.ts +++ b/bitwarden_license/bit-cli/src/service-container.ts @@ -1,7 +1,24 @@ +import { + OrganizationAuthRequestService, + OrganizationAuthRequestApiService, +} from "@bitwarden/bit-common/admin-console/auth-requests"; import { ServiceContainer as OssServiceContainer } from "@bitwarden/cli/service-container"; /** * Instantiates services and makes them available for dependency injection. * Any Bitwarden-licensed services should be registered here. */ -export class ServiceContainer extends OssServiceContainer {} +export class ServiceContainer extends OssServiceContainer { + organizationAuthRequestApiService: OrganizationAuthRequestApiService; + organizationAuthRequestService: OrganizationAuthRequestService; + + constructor() { + super(); + this.organizationAuthRequestApiService = new OrganizationAuthRequestApiService(this.apiService); + this.organizationAuthRequestService = new OrganizationAuthRequestService( + this.organizationAuthRequestApiService, + this.cryptoService, + this.organizationUserService, + ); + } +} diff --git a/bitwarden_license/bit-cli/tsconfig.json b/bitwarden_license/bit-cli/tsconfig.json index 1989aa08f9..e8a57e5eb0 100644 --- a/bitwarden_license/bit-cli/tsconfig.json +++ b/bitwarden_license/bit-cli/tsconfig.json @@ -21,7 +21,8 @@ "@bitwarden/vault-export-core": [ "../../libs/tools/export/vault-export/vault-export-core/src" ], - "@bitwarden/node/*": ["../../libs/node/src/*"] + "@bitwarden/node/*": ["../../libs/node/src/*"], + "@bitwarden/bit-common/*": ["../../bitwarden_license/bit-common/src/*"] } }, "include": ["src", "src/**/*.spec.ts"] diff --git a/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts b/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts index d8c4bacd69..517dc8699b 100644 --- a/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts +++ b/bitwarden_license/bit-common/src/admin-console/auth-requests/index.ts @@ -1,2 +1,4 @@ export * from "./pending-organization-auth-request.response"; export * from "./organization-auth-request.service"; +export * from "./organization-auth-request-api.service"; +export * from "./pending-auth-request.view"; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index d16b0a8aa2..ffcfcd0ad8 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -33,6 +33,7 @@ *ngIf="canAccessBilling$ | async" > + + + + {{ "loading" | i18n }} + + + + +

    + {{ "accountCredit" | i18n }} +

    +

    {{ accountCredit | currency: "$" }}

    +

    {{ "creditAppliedDesc" | i18n }}

    + +
    + + +

    {{ "paymentMethod" | i18n }}

    +

    {{ "noPaymentMethod" | i18n }}

    + + +

    + + {{ paymentMethodDescription }} +

    +
    + +
    + + +

    {{ "taxInformation" | i18n }}

    +

    {{ "taxInformationDesc" | i18n }}

    + +
    +
    diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.ts new file mode 100644 index 0000000000..42a7dbdec0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.ts @@ -0,0 +1,140 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { from, lastValueFrom, Subject, switchMap } from "rxjs"; +import { takeUntil } from "rxjs/operators"; + +import { openAddAccountCreditDialog } from "@bitwarden/angular/billing/components"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { MaskedPaymentMethod, TaxInformation } from "@bitwarden/common/billing/models/domain"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; +import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { + openProviderSelectPaymentMethodDialog, + ProviderSelectPaymentMethodDialogResultType, +} from "./provider-select-payment-method-dialog.component"; + +@Component({ + selector: "app-provider-payment-method", + templateUrl: "./provider-payment-method.component.html", +}) +export class ProviderPaymentMethodComponent implements OnInit, OnDestroy { + protected providerId: string; + protected loading: boolean; + + protected accountCredit: number; + protected maskedPaymentMethod: MaskedPaymentMethod; + protected taxInformation: TaxInformation; + + private destroy$ = new Subject(); + + constructor( + private activatedRoute: ActivatedRoute, + private billingApiService: BillingApiServiceAbstraction, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + addAccountCredit = () => + openAddAccountCreditDialog(this.dialogService, { + data: { + providerId: this.providerId, + }, + }); + + changePaymentMethod = async () => { + const dialogRef = openProviderSelectPaymentMethodDialog(this.dialogService, { + data: { + providerId: this.providerId, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result == ProviderSelectPaymentMethodDialogResultType.Submitted) { + await this.load(); + } + }; + + async load() { + this.loading = true; + const paymentInformation = await this.billingApiService.getProviderPaymentInformation( + this.providerId, + ); + this.accountCredit = paymentInformation.accountCredit; + this.maskedPaymentMethod = MaskedPaymentMethod.from(paymentInformation.paymentMethod); + this.taxInformation = TaxInformation.from(paymentInformation.taxInformation); + this.loading = false; + } + + onDataUpdated = async () => await this.load(); + + updateTaxInformation = async (taxInformation: TaxInformation) => { + const request = ExpandedTaxInfoUpdateRequest.From(taxInformation); + await this.billingApiService.updateProviderTaxInformation(this.providerId, request); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("updatedTaxInformation"), + }); + }; + + verifyBankAccount = async (amount1: number, amount2: number) => { + const request = new VerifyBankAccountRequest(amount1, amount2); + await this.billingApiService.verifyProviderBankAccount(this.providerId, request); + }; + + ngOnInit() { + this.activatedRoute.params + .pipe( + switchMap(({ providerId }) => { + this.providerId = providerId; + return from(this.load()); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected get hasPaymentMethod(): boolean { + return !!this.maskedPaymentMethod; + } + + protected get hasUnverifiedPaymentMethod(): boolean { + return !!this.maskedPaymentMethod && this.maskedPaymentMethod.needsVerification; + } + + protected get paymentMethodClass(): string[] { + switch (this.maskedPaymentMethod.type) { + case PaymentMethodType.Card: + return ["bwi-credit-card"]; + case PaymentMethodType.BankAccount: + return ["bwi-bank"]; + case PaymentMethodType.PayPal: + return ["bwi-paypal tw-text-primary"]; + default: + return []; + } + } + + protected get paymentMethodDescription(): string { + let description = this.maskedPaymentMethod.description; + if (this.maskedPaymentMethod.type === PaymentMethodType.BankAccount) { + if (this.hasUnverifiedPaymentMethod) { + description += " - " + this.i18nService.t("unverified"); + } else { + description += " - " + this.i18nService.t("verified"); + } + } + return description; + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-select-payment-method-dialog.component.html b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-select-payment-method-dialog.component.html new file mode 100644 index 0000000000..03e8405a48 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-select-payment-method-dialog.component.html @@ -0,0 +1,18 @@ +
    + + + {{ "addPaymentMethod" | i18n }} + + + + + + + + + +
    diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-select-payment-method-dialog.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-select-payment-method-dialog.component.ts new file mode 100644 index 0000000000..09a293d12d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-select-payment-method-dialog.component.ts @@ -0,0 +1,60 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, EventEmitter, Inject, Output, ViewChild } from "@angular/core"; +import { FormGroup } from "@angular/forms"; + +import { SelectPaymentMethodComponent } from "@bitwarden/angular/billing/components"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { TokenizedPaymentMethodRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-method.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +type ProviderSelectPaymentMethodDialogParams = { + providerId: string; +}; + +export enum ProviderSelectPaymentMethodDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +export const openProviderSelectPaymentMethodDialog = ( + dialogService: DialogService, + dialogConfig: DialogConfig, +) => + dialogService.open< + ProviderSelectPaymentMethodDialogResultType, + ProviderSelectPaymentMethodDialogParams + >(ProviderSelectPaymentMethodDialogComponent, dialogConfig); + +@Component({ + templateUrl: "provider-select-payment-method-dialog.component.html", +}) +export class ProviderSelectPaymentMethodDialogComponent { + @ViewChild(SelectPaymentMethodComponent) + selectPaymentMethodComponent: SelectPaymentMethodComponent; + @Output() providerPaymentMethodUpdated = new EventEmitter(); + + protected readonly formGroup = new FormGroup({}); + protected readonly ResultType = ProviderSelectPaymentMethodDialogResultType; + + constructor( + private billingApiService: BillingApiServiceAbstraction, + @Inject(DIALOG_DATA) private dialogParams: ProviderSelectPaymentMethodDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + submit = async () => { + const tokenizedPaymentMethod = await this.selectPaymentMethodComponent.tokenizePaymentMethod(); + const request = TokenizedPaymentMethodRequest.From(tokenizedPaymentMethod); + await this.billingApiService.updateProviderPaymentMethod(this.dialogParams.providerId, request); + this.providerPaymentMethodUpdated.emit(); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("updatedPaymentMethod"), + }); + this.dialogRef.close(this.ResultType.Submitted); + }; +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/provider-subscription.component.html b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html similarity index 100% rename from bitwarden_license/bit-web/src/app/billing/providers/provider-subscription.component.html rename to bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.html diff --git a/bitwarden_license/bit-web/src/app/billing/providers/provider-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts similarity index 100% rename from bitwarden_license/bit-web/src/app/billing/providers/provider-subscription.component.ts rename to bitwarden_license/bit-web/src/app/billing/providers/subscription/provider-subscription.component.ts diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html index e926ba6a13..454b497fcd 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.html @@ -78,9 +78,11 @@ -
    - {{ emptyMessage }} -
    + + + {{ emptyMessage }} + +
    diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html new file mode 100644 index 0000000000..c9c0c296ad --- /dev/null +++ b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.html @@ -0,0 +1,55 @@ +
    + + +

    {{ "creditDelayed" | i18n }}

    +
    + + + {{ "payPal" | i18n }} + + + {{ "bitcoin" | i18n }} + + +
    +
    + + {{ "amount" | i18n }} + + $USD + +
    +
    + + + + +
    +
    +
    + + + + + + + + + + + + + + + +
    diff --git a/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts new file mode 100644 index 0000000000..d3c262c4b7 --- /dev/null +++ b/libs/angular/src/billing/components/add-account-credit-dialog/add-account-credit-dialog.component.ts @@ -0,0 +1,153 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/bit-pay-invoice.request"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +export type AddAccountCreditDialogParams = { + organizationId?: string; + providerId?: string; +}; + +export enum AddAccountCreditDialogResultType { + Closed = "closed", + Submitted = "submitted", +} + +export const openAddAccountCreditDialog = ( + dialogService: DialogService, + dialogConfig: DialogConfig, +) => + dialogService.open( + AddAccountCreditDialogComponent, + dialogConfig, + ); + +type PayPalConfig = { + businessId?: string; + buttonAction?: string; + returnUrl?: string; + customField?: string; + subject?: string; +}; + +@Component({ + templateUrl: "./add-account-credit-dialog.component.html", +}) +export class AddAccountCreditDialogComponent implements OnInit { + @ViewChild("payPalForm", { read: ElementRef, static: true }) payPalForm: ElementRef; + protected formGroup = new FormGroup({ + paymentMethod: new FormControl(PaymentMethodType.PayPal), + creditAmount: new FormControl(null, [Validators.required, Validators.min(0.01)]), + }); + protected payPalConfig: PayPalConfig; + protected ResultType = AddAccountCreditDialogResultType; + + private organization?: Organization; + private provider?: Provider; + private user?: { id: UserId } & AccountInfo; + + constructor( + private accountService: AccountService, + private apiService: ApiService, + private configService: ConfigService, + @Inject(DIALOG_DATA) private dialogParams: AddAccountCreditDialogParams, + private dialogRef: DialogRef, + private organizationService: OrganizationService, + private platformUtilsService: PlatformUtilsService, + private providerService: ProviderService, + ) { + this.payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; + } + + protected readonly paymentMethodType = PaymentMethodType; + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + if (this.formGroup.value.paymentMethod === PaymentMethodType.PayPal) { + this.payPalForm.nativeElement.submit(); + return; + } + + if (this.formGroup.value.paymentMethod === PaymentMethodType.BitPay) { + const request = this.getBitPayInvoiceRequest(); + const bitPayUrl = await this.apiService.postBitPayInvoice(request); + this.platformUtilsService.launchUri(bitPayUrl); + return; + } + + this.dialogRef.close(AddAccountCreditDialogResultType.Submitted); + }; + + async ngOnInit(): Promise { + let payPalCustomField: string; + + if (this.dialogParams.organizationId) { + this.formGroup.patchValue({ + creditAmount: 20.0, + }); + this.organization = await this.organizationService.get(this.dialogParams.organizationId); + payPalCustomField = "organization_id:" + this.organization.id; + this.payPalConfig.subject = this.organization.name; + } else if (this.dialogParams.providerId) { + this.formGroup.patchValue({ + creditAmount: 20.0, + }); + this.provider = await this.providerService.get(this.dialogParams.providerId); + payPalCustomField = "provider_id:" + this.provider.id; + this.payPalConfig.subject = this.provider.name; + } else { + this.formGroup.patchValue({ + creditAmount: 10.0, + }); + this.user = await firstValueFrom(this.accountService.activeAccount$); + payPalCustomField = "user_id:" + this.user.id; + this.payPalConfig.subject = this.user.email; + } + + const region = await firstValueFrom(this.configService.cloudRegion$); + + payPalCustomField += ",account_credit:1"; + payPalCustomField += `,region:${region}`; + + this.payPalConfig.customField = payPalCustomField; + this.payPalConfig.returnUrl = window.location.href; + } + + getBitPayInvoiceRequest(): BitPayInvoiceRequest { + const request = new BitPayInvoiceRequest(); + if (this.organization) { + request.name = this.organization.name; + request.organizationId = this.organization.id; + } else if (this.provider) { + request.name = this.provider.name; + request.providerId = this.provider.id; + } else { + request.email = this.user.email; + request.userId = this.user.id; + } + + request.credit = true; + request.amount = this.formGroup.value.creditAmount; + request.returnUrl = window.location.href; + + return request; + } +} diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts new file mode 100644 index 0000000000..748a005df8 --- /dev/null +++ b/libs/angular/src/billing/components/index.ts @@ -0,0 +1,4 @@ +export * from "./add-account-credit-dialog/add-account-credit-dialog.component"; +export * from "./manage-tax-information/manage-tax-information.component"; +export * from "./select-payment-method/select-payment-method.component"; +export * from "./verify-bank-account/verify-bank-account.component"; diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html new file mode 100644 index 0000000000..f9cfa8e0fa --- /dev/null +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html @@ -0,0 +1,72 @@ +
    +
    +
    + + {{ "country" | i18n }} + + + + +
    +
    + + {{ "zipPostalCode" | i18n }} + + +
    +
    + + + {{ "includeVAT" | i18n }} + +
    +
    +
    +
    + + {{ "taxIdNumber" | i18n }} + + +
    +
    +
    +
    + + {{ "address1" | i18n }} + + +
    +
    + + {{ "address2" | i18n }} + + +
    +
    + + {{ "cityTown" | i18n }} + + +
    +
    + + {{ "stateProvince" | i18n }} + + +
    +
    + +
    diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts new file mode 100644 index 0000000000..58342548ca --- /dev/null +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts @@ -0,0 +1,406 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; + +import { TaxInformation } from "@bitwarden/common/billing/models/domain"; + +type Country = { + name: string; + value: string; + disabled: boolean; +}; + +@Component({ + selector: "app-manage-tax-information", + templateUrl: "./manage-tax-information.component.html", +}) +export class ManageTaxInformationComponent implements OnInit { + @Input({ required: true }) taxInformation: TaxInformation; + @Input() onSubmit?: (taxInformation: TaxInformation) => Promise; + @Output() taxInformationUpdated = new EventEmitter(); + + protected formGroup = this.formBuilder.group({ + country: ["", Validators.required], + postalCode: ["", Validators.required], + includeTaxId: false, + taxId: "", + line1: "", + line2: "", + city: "", + state: "", + }); + + constructor(private formBuilder: FormBuilder) {} + + submit = async () => { + await this.onSubmit({ + country: this.formGroup.value.country, + postalCode: this.formGroup.value.postalCode, + taxId: this.formGroup.value.taxId, + line1: this.formGroup.value.line1, + line2: this.formGroup.value.line2, + city: this.formGroup.value.city, + state: this.formGroup.value.state, + }); + + this.taxInformationUpdated.emit(); + }; + + async ngOnInit() { + if (this.taxInformation) { + this.formGroup.patchValue({ + ...this.taxInformation, + includeTaxId: + this.countrySupportsTax(this.taxInformation.country) && + (!!this.taxInformation.taxId || + !!this.taxInformation.line1 || + !!this.taxInformation.line2 || + !!this.taxInformation.city || + !!this.taxInformation.state), + }); + } + } + + protected countrySupportsTax(countryCode: string) { + return this.taxSupportedCountryCodes.includes(countryCode); + } + + protected get includeTaxIdIsSelected() { + return this.formGroup.value.includeTaxId; + } + + protected get selectionSupportsAdditionalOptions() { + return ( + this.formGroup.value.country !== "US" && this.countrySupportsTax(this.formGroup.value.country) + ); + } + + protected countries: Country[] = [ + { name: "-- Select --", value: "", disabled: false }, + { name: "United States", value: "US", disabled: false }, + { name: "China", value: "CN", disabled: false }, + { name: "France", value: "FR", disabled: false }, + { name: "Germany", value: "DE", disabled: false }, + { name: "Canada", value: "CA", disabled: false }, + { name: "United Kingdom", value: "GB", disabled: false }, + { name: "Australia", value: "AU", disabled: false }, + { name: "India", value: "IN", disabled: false }, + { name: "", value: "-", disabled: true }, + { name: "Afghanistan", value: "AF", disabled: false }, + { name: "Åland Islands", value: "AX", disabled: false }, + { name: "Albania", value: "AL", disabled: false }, + { name: "Algeria", value: "DZ", disabled: false }, + { name: "American Samoa", value: "AS", disabled: false }, + { name: "Andorra", value: "AD", disabled: false }, + { name: "Angola", value: "AO", disabled: false }, + { name: "Anguilla", value: "AI", disabled: false }, + { name: "Antarctica", value: "AQ", disabled: false }, + { name: "Antigua and Barbuda", value: "AG", disabled: false }, + { name: "Argentina", value: "AR", disabled: false }, + { name: "Armenia", value: "AM", disabled: false }, + { name: "Aruba", value: "AW", disabled: false }, + { name: "Austria", value: "AT", disabled: false }, + { name: "Azerbaijan", value: "AZ", disabled: false }, + { name: "Bahamas", value: "BS", disabled: false }, + { name: "Bahrain", value: "BH", disabled: false }, + { name: "Bangladesh", value: "BD", disabled: false }, + { name: "Barbados", value: "BB", disabled: false }, + { name: "Belarus", value: "BY", disabled: false }, + { name: "Belgium", value: "BE", disabled: false }, + { name: "Belize", value: "BZ", disabled: false }, + { name: "Benin", value: "BJ", disabled: false }, + { name: "Bermuda", value: "BM", disabled: false }, + { name: "Bhutan", value: "BT", disabled: false }, + { name: "Bolivia, Plurinational State of", value: "BO", disabled: false }, + { name: "Bonaire, Sint Eustatius and Saba", value: "BQ", disabled: false }, + { name: "Bosnia and Herzegovina", value: "BA", disabled: false }, + { name: "Botswana", value: "BW", disabled: false }, + { name: "Bouvet Island", value: "BV", disabled: false }, + { name: "Brazil", value: "BR", disabled: false }, + { name: "British Indian Ocean Territory", value: "IO", disabled: false }, + { name: "Brunei Darussalam", value: "BN", disabled: false }, + { name: "Bulgaria", value: "BG", disabled: false }, + { name: "Burkina Faso", value: "BF", disabled: false }, + { name: "Burundi", value: "BI", disabled: false }, + { name: "Cambodia", value: "KH", disabled: false }, + { name: "Cameroon", value: "CM", disabled: false }, + { name: "Cape Verde", value: "CV", disabled: false }, + { name: "Cayman Islands", value: "KY", disabled: false }, + { name: "Central African Republic", value: "CF", disabled: false }, + { name: "Chad", value: "TD", disabled: false }, + { name: "Chile", value: "CL", disabled: false }, + { name: "Christmas Island", value: "CX", disabled: false }, + { name: "Cocos (Keeling) Islands", value: "CC", disabled: false }, + { name: "Colombia", value: "CO", disabled: false }, + { name: "Comoros", value: "KM", disabled: false }, + { name: "Congo", value: "CG", disabled: false }, + { name: "Congo, the Democratic Republic of the", value: "CD", disabled: false }, + { name: "Cook Islands", value: "CK", disabled: false }, + { name: "Costa Rica", value: "CR", disabled: false }, + { name: "Côte d'Ivoire", value: "CI", disabled: false }, + { name: "Croatia", value: "HR", disabled: false }, + { name: "Cuba", value: "CU", disabled: false }, + { name: "Curaçao", value: "CW", disabled: false }, + { name: "Cyprus", value: "CY", disabled: false }, + { name: "Czech Republic", value: "CZ", disabled: false }, + { name: "Denmark", value: "DK", disabled: false }, + { name: "Djibouti", value: "DJ", disabled: false }, + { name: "Dominica", value: "DM", disabled: false }, + { name: "Dominican Republic", value: "DO", disabled: false }, + { name: "Ecuador", value: "EC", disabled: false }, + { name: "Egypt", value: "EG", disabled: false }, + { name: "El Salvador", value: "SV", disabled: false }, + { name: "Equatorial Guinea", value: "GQ", disabled: false }, + { name: "Eritrea", value: "ER", disabled: false }, + { name: "Estonia", value: "EE", disabled: false }, + { name: "Ethiopia", value: "ET", disabled: false }, + { name: "Falkland Islands (Malvinas)", value: "FK", disabled: false }, + { name: "Faroe Islands", value: "FO", disabled: false }, + { name: "Fiji", value: "FJ", disabled: false }, + { name: "Finland", value: "FI", disabled: false }, + { name: "French Guiana", value: "GF", disabled: false }, + { name: "French Polynesia", value: "PF", disabled: false }, + { name: "French Southern Territories", value: "TF", disabled: false }, + { name: "Gabon", value: "GA", disabled: false }, + { name: "Gambia", value: "GM", disabled: false }, + { name: "Georgia", value: "GE", disabled: false }, + { name: "Ghana", value: "GH", disabled: false }, + { name: "Gibraltar", value: "GI", disabled: false }, + { name: "Greece", value: "GR", disabled: false }, + { name: "Greenland", value: "GL", disabled: false }, + { name: "Grenada", value: "GD", disabled: false }, + { name: "Guadeloupe", value: "GP", disabled: false }, + { name: "Guam", value: "GU", disabled: false }, + { name: "Guatemala", value: "GT", disabled: false }, + { name: "Guernsey", value: "GG", disabled: false }, + { name: "Guinea", value: "GN", disabled: false }, + { name: "Guinea-Bissau", value: "GW", disabled: false }, + { name: "Guyana", value: "GY", disabled: false }, + { name: "Haiti", value: "HT", disabled: false }, + { name: "Heard Island and McDonald Islands", value: "HM", disabled: false }, + { name: "Holy See (Vatican City State)", value: "VA", disabled: false }, + { name: "Honduras", value: "HN", disabled: false }, + { name: "Hong Kong", value: "HK", disabled: false }, + { name: "Hungary", value: "HU", disabled: false }, + { name: "Iceland", value: "IS", disabled: false }, + { name: "Indonesia", value: "ID", disabled: false }, + { name: "Iran, Islamic Republic of", value: "IR", disabled: false }, + { name: "Iraq", value: "IQ", disabled: false }, + { name: "Ireland", value: "IE", disabled: false }, + { name: "Isle of Man", value: "IM", disabled: false }, + { name: "Israel", value: "IL", disabled: false }, + { name: "Italy", value: "IT", disabled: false }, + { name: "Jamaica", value: "JM", disabled: false }, + { name: "Japan", value: "JP", disabled: false }, + { name: "Jersey", value: "JE", disabled: false }, + { name: "Jordan", value: "JO", disabled: false }, + { name: "Kazakhstan", value: "KZ", disabled: false }, + { name: "Kenya", value: "KE", disabled: false }, + { name: "Kiribati", value: "KI", disabled: false }, + { name: "Korea, Democratic People's Republic of", value: "KP", disabled: false }, + { name: "Korea, Republic of", value: "KR", disabled: false }, + { name: "Kuwait", value: "KW", disabled: false }, + { name: "Kyrgyzstan", value: "KG", disabled: false }, + { name: "Lao People's Democratic Republic", value: "LA", disabled: false }, + { name: "Latvia", value: "LV", disabled: false }, + { name: "Lebanon", value: "LB", disabled: false }, + { name: "Lesotho", value: "LS", disabled: false }, + { name: "Liberia", value: "LR", disabled: false }, + { name: "Libya", value: "LY", disabled: false }, + { name: "Liechtenstein", value: "LI", disabled: false }, + { name: "Lithuania", value: "LT", disabled: false }, + { name: "Luxembourg", value: "LU", disabled: false }, + { name: "Macao", value: "MO", disabled: false }, + { name: "Macedonia, the former Yugoslav Republic of", value: "MK", disabled: false }, + { name: "Madagascar", value: "MG", disabled: false }, + { name: "Malawi", value: "MW", disabled: false }, + { name: "Malaysia", value: "MY", disabled: false }, + { name: "Maldives", value: "MV", disabled: false }, + { name: "Mali", value: "ML", disabled: false }, + { name: "Malta", value: "MT", disabled: false }, + { name: "Marshall Islands", value: "MH", disabled: false }, + { name: "Martinique", value: "MQ", disabled: false }, + { name: "Mauritania", value: "MR", disabled: false }, + { name: "Mauritius", value: "MU", disabled: false }, + { name: "Mayotte", value: "YT", disabled: false }, + { name: "Mexico", value: "MX", disabled: false }, + { name: "Micronesia, Federated States of", value: "FM", disabled: false }, + { name: "Moldova, Republic of", value: "MD", disabled: false }, + { name: "Monaco", value: "MC", disabled: false }, + { name: "Mongolia", value: "MN", disabled: false }, + { name: "Montenegro", value: "ME", disabled: false }, + { name: "Montserrat", value: "MS", disabled: false }, + { name: "Morocco", value: "MA", disabled: false }, + { name: "Mozambique", value: "MZ", disabled: false }, + { name: "Myanmar", value: "MM", disabled: false }, + { name: "Namibia", value: "NA", disabled: false }, + { name: "Nauru", value: "NR", disabled: false }, + { name: "Nepal", value: "NP", disabled: false }, + { name: "Netherlands", value: "NL", disabled: false }, + { name: "New Caledonia", value: "NC", disabled: false }, + { name: "New Zealand", value: "NZ", disabled: false }, + { name: "Nicaragua", value: "NI", disabled: false }, + { name: "Niger", value: "NE", disabled: false }, + { name: "Nigeria", value: "NG", disabled: false }, + { name: "Niue", value: "NU", disabled: false }, + { name: "Norfolk Island", value: "NF", disabled: false }, + { name: "Northern Mariana Islands", value: "MP", disabled: false }, + { name: "Norway", value: "NO", disabled: false }, + { name: "Oman", value: "OM", disabled: false }, + { name: "Pakistan", value: "PK", disabled: false }, + { name: "Palau", value: "PW", disabled: false }, + { name: "Palestinian Territory, Occupied", value: "PS", disabled: false }, + { name: "Panama", value: "PA", disabled: false }, + { name: "Papua New Guinea", value: "PG", disabled: false }, + { name: "Paraguay", value: "PY", disabled: false }, + { name: "Peru", value: "PE", disabled: false }, + { name: "Philippines", value: "PH", disabled: false }, + { name: "Pitcairn", value: "PN", disabled: false }, + { name: "Poland", value: "PL", disabled: false }, + { name: "Portugal", value: "PT", disabled: false }, + { name: "Puerto Rico", value: "PR", disabled: false }, + { name: "Qatar", value: "QA", disabled: false }, + { name: "Réunion", value: "RE", disabled: false }, + { name: "Romania", value: "RO", disabled: false }, + { name: "Russian Federation", value: "RU", disabled: false }, + { name: "Rwanda", value: "RW", disabled: false }, + { name: "Saint Barthélemy", value: "BL", disabled: false }, + { name: "Saint Helena, Ascension and Tristan da Cunha", value: "SH", disabled: false }, + { name: "Saint Kitts and Nevis", value: "KN", disabled: false }, + { name: "Saint Lucia", value: "LC", disabled: false }, + { name: "Saint Martin (French part)", value: "MF", disabled: false }, + { name: "Saint Pierre and Miquelon", value: "PM", disabled: false }, + { name: "Saint Vincent and the Grenadines", value: "VC", disabled: false }, + { name: "Samoa", value: "WS", disabled: false }, + { name: "San Marino", value: "SM", disabled: false }, + { name: "Sao Tome and Principe", value: "ST", disabled: false }, + { name: "Saudi Arabia", value: "SA", disabled: false }, + { name: "Senegal", value: "SN", disabled: false }, + { name: "Serbia", value: "RS", disabled: false }, + { name: "Seychelles", value: "SC", disabled: false }, + { name: "Sierra Leone", value: "SL", disabled: false }, + { name: "Singapore", value: "SG", disabled: false }, + { name: "Sint Maarten (Dutch part)", value: "SX", disabled: false }, + { name: "Slovakia", value: "SK", disabled: false }, + { name: "Slovenia", value: "SI", disabled: false }, + { name: "Solomon Islands", value: "SB", disabled: false }, + { name: "Somalia", value: "SO", disabled: false }, + { name: "South Africa", value: "ZA", disabled: false }, + { name: "South Georgia and the South Sandwich Islands", value: "GS", disabled: false }, + { name: "South Sudan", value: "SS", disabled: false }, + { name: "Spain", value: "ES", disabled: false }, + { name: "Sri Lanka", value: "LK", disabled: false }, + { name: "Sudan", value: "SD", disabled: false }, + { name: "Suriname", value: "SR", disabled: false }, + { name: "Svalbard and Jan Mayen", value: "SJ", disabled: false }, + { name: "Swaziland", value: "SZ", disabled: false }, + { name: "Sweden", value: "SE", disabled: false }, + { name: "Switzerland", value: "CH", disabled: false }, + { name: "Syrian Arab Republic", value: "SY", disabled: false }, + { name: "Taiwan", value: "TW", disabled: false }, + { name: "Tajikistan", value: "TJ", disabled: false }, + { name: "Tanzania, United Republic of", value: "TZ", disabled: false }, + { name: "Thailand", value: "TH", disabled: false }, + { name: "Timor-Leste", value: "TL", disabled: false }, + { name: "Togo", value: "TG", disabled: false }, + { name: "Tokelau", value: "TK", disabled: false }, + { name: "Tonga", value: "TO", disabled: false }, + { name: "Trinidad and Tobago", value: "TT", disabled: false }, + { name: "Tunisia", value: "TN", disabled: false }, + { name: "Turkey", value: "TR", disabled: false }, + { name: "Turkmenistan", value: "TM", disabled: false }, + { name: "Turks and Caicos Islands", value: "TC", disabled: false }, + { name: "Tuvalu", value: "TV", disabled: false }, + { name: "Uganda", value: "UG", disabled: false }, + { name: "Ukraine", value: "UA", disabled: false }, + { name: "United Arab Emirates", value: "AE", disabled: false }, + { name: "United States Minor Outlying Islands", value: "UM", disabled: false }, + { name: "Uruguay", value: "UY", disabled: false }, + { name: "Uzbekistan", value: "UZ", disabled: false }, + { name: "Vanuatu", value: "VU", disabled: false }, + { name: "Venezuela, Bolivarian Republic of", value: "VE", disabled: false }, + { name: "Viet Nam", value: "VN", disabled: false }, + { name: "Virgin Islands, British", value: "VG", disabled: false }, + { name: "Virgin Islands, U.S.", value: "VI", disabled: false }, + { name: "Wallis and Futuna", value: "WF", disabled: false }, + { name: "Western Sahara", value: "EH", disabled: false }, + { name: "Yemen", value: "YE", disabled: false }, + { name: "Zambia", value: "ZM", disabled: false }, + { name: "Zimbabwe", value: "ZW", disabled: false }, + ]; + + private taxSupportedCountryCodes: string[] = [ + "CN", + "FR", + "DE", + "CA", + "GB", + "AU", + "IN", + "AD", + "AR", + "AT", + "BE", + "BO", + "BR", + "BG", + "CL", + "CO", + "CR", + "HR", + "CY", + "CZ", + "DK", + "DO", + "EC", + "EG", + "SV", + "EE", + "FI", + "GE", + "GR", + "HK", + "HU", + "IS", + "ID", + "IQ", + "IE", + "IL", + "IT", + "JP", + "KE", + "KR", + "LV", + "LI", + "LT", + "LU", + "MY", + "MT", + "MX", + "NL", + "NZ", + "NO", + "PE", + "PH", + "PL", + "PT", + "RO", + "RU", + "SA", + "RS", + "SG", + "SK", + "SI", + "ZA", + "ES", + "SE", + "CH", + "TW", + "TH", + "TR", + "UA", + "AE", + "UY", + "VE", + "VN", + ]; +} diff --git a/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html b/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html new file mode 100644 index 0000000000..7add3f6d35 --- /dev/null +++ b/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.html @@ -0,0 +1,151 @@ +
    +
    + + + + + {{ "creditCard" | i18n }} + + + + + + {{ "bankAccount" | i18n }} + + + + + + {{ "payPal" | i18n }} + + + + + + {{ "accountCredit" | i18n }} + + + +
    + + +
    +
    + +
    +
    +
    + Visa, MasterCard, Discover, AmEx, JCB, Diners Club, UnionPay +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + + + + {{ "verifyBankAccountInitialDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }} + +
    + + {{ "routingNumber" | i18n }} + + + + {{ "accountNumber" | i18n }} + + + + {{ "accountHolderName" | i18n }} + + + + {{ "bankAccountType" | i18n }} + + + + + + +
    +
    + + +
    +
    + {{ "paypalClickSubmit" | i18n }} +
    +
    + + + + {{ "makeSureEnoughCredit" | i18n }} + + + +
    diff --git a/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.ts b/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.ts new file mode 100644 index 0000000000..4dc39334a7 --- /dev/null +++ b/libs/angular/src/billing/components/select-payment-method/select-payment-method.component.ts @@ -0,0 +1,159 @@ +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; +import { Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; + +import { + BillingApiServiceAbstraction, + BraintreeServiceAbstraction, + StripeServiceAbstraction, +} from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { TokenizedPaymentMethod } from "@bitwarden/common/billing/models/domain"; + +@Component({ + selector: "app-select-payment-method", + templateUrl: "./select-payment-method.component.html", +}) +export class SelectPaymentMethodComponent implements OnInit, OnDestroy { + @Input() protected showAccountCredit: boolean = true; + @Input() protected showBankAccount: boolean = true; + @Input() protected showPayPal: boolean = true; + @Input() private startWith: PaymentMethodType = PaymentMethodType.Card; + @Input() protected onSubmit: (tokenizedPaymentMethod: TokenizedPaymentMethod) => Promise; + + private destroy$ = new Subject(); + + protected formGroup = this.formBuilder.group({ + paymentMethod: [this.startWith], + bankInformation: this.formBuilder.group({ + routingNumber: ["", [Validators.required]], + accountNumber: ["", [Validators.required]], + accountHolderName: ["", [Validators.required]], + accountHolderType: ["", [Validators.required]], + }), + }); + protected PaymentMethodType = PaymentMethodType; + + constructor( + private billingApiService: BillingApiServiceAbstraction, + private braintreeService: BraintreeServiceAbstraction, + private formBuilder: FormBuilder, + private stripeService: StripeServiceAbstraction, + ) {} + + async tokenizePaymentMethod(): Promise { + const type = this.selected; + + if (this.usingStripe) { + const clientSecret = await this.billingApiService.createSetupIntent(type); + + if (this.usingBankAccount) { + const token = await this.stripeService.setupBankAccountPaymentMethod(clientSecret, { + accountHolderName: this.formGroup.value.bankInformation.accountHolderName, + routingNumber: this.formGroup.value.bankInformation.routingNumber, + accountNumber: this.formGroup.value.bankInformation.accountNumber, + accountHolderType: this.formGroup.value.bankInformation.accountHolderType, + }); + return { + type, + token, + }; + } + + if (this.usingCard) { + const token = await this.stripeService.setupCardPaymentMethod(clientSecret); + return { + type, + token, + }; + } + } + + if (this.usingPayPal) { + const token = await this.braintreeService.requestPaymentMethod(); + return { + type, + token, + }; + } + + return null; + } + + submit = async () => { + const tokenizedPaymentMethod = await this.tokenizePaymentMethod(); + await this.onSubmit(tokenizedPaymentMethod); + }; + + ngOnInit(): void { + this.stripeService.loadStripe( + { + cardNumber: "#stripe-card-number", + cardExpiry: "#stripe-card-expiry", + cardCvc: "#stripe-card-cvc", + }, + this.startWith === PaymentMethodType.Card, + ); + + if (this.showPayPal) { + this.braintreeService.loadBraintree( + "#braintree-container", + this.startWith === PaymentMethodType.PayPal, + ); + } + + this.formGroup + .get("paymentMethod") + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((type) => { + this.onPaymentMethodChange(type); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.stripeService.unloadStripe(); + if (this.showPayPal) { + this.braintreeService.unloadBraintree(); + } + } + + private onPaymentMethodChange(type: PaymentMethodType): void { + switch (type) { + case PaymentMethodType.Card: { + this.stripeService.mountElements(); + break; + } + case PaymentMethodType.PayPal: { + this.braintreeService.createDropin(); + break; + } + } + } + + private get selected(): PaymentMethodType { + return this.formGroup.value.paymentMethod; + } + + protected get usingAccountCredit(): boolean { + return this.selected === PaymentMethodType.Credit; + } + + protected get usingBankAccount(): boolean { + return this.selected === PaymentMethodType.BankAccount; + } + + protected get usingCard(): boolean { + return this.selected === PaymentMethodType.Card; + } + + protected get usingPayPal(): boolean { + return this.selected === PaymentMethodType.PayPal; + } + + private get usingStripe(): boolean { + return this.usingBankAccount || this.usingCard; + } +} diff --git a/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.html b/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.html new file mode 100644 index 0000000000..f338f5b081 --- /dev/null +++ b/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.html @@ -0,0 +1,18 @@ + +

    {{ "verifyBankAccountDesc" | i18n }} {{ "verifyBankAccountFailureWarning" | i18n }}

    +
    + + {{ "amountX" | i18n: "1" }} + + $0. + + + {{ "amountX" | i18n: "2" }} + + $0. + + +
    +
    diff --git a/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.ts b/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.ts new file mode 100644 index 0000000000..c8abb65d81 --- /dev/null +++ b/libs/angular/src/billing/components/verify-bank-account/verify-bank-account.component.ts @@ -0,0 +1,33 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; + +@Component({ + selector: "app-verify-bank-account", + templateUrl: "./verify-bank-account.component.html", +}) +export class VerifyBankAccountComponent { + @Input() onSubmit?: (amount1: number, amount2: number) => Promise; + @Output() verificationSubmitted = new EventEmitter(); + + protected formGroup = this.formBuilder.group({ + amount1: new FormControl(null, [ + Validators.required, + Validators.min(0), + Validators.max(99), + ]), + amount2: new FormControl(null, [ + Validators.required, + Validators.min(0), + Validators.max(99), + ]), + }); + + constructor(private formBuilder: FormBuilder) {} + + submit = async () => { + if (this.onSubmit) { + await this.onSubmit(this.formGroup.value.amount1, this.formGroup.value.amount2); + } + this.verificationSubmitted.emit(); + }; +} diff --git a/libs/angular/src/billing/images/cards.png b/libs/angular/src/billing/images/cards.png new file mode 100644 index 0000000000..bd43abe54c Binary files /dev/null and b/libs/angular/src/billing/images/cards.png differ diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 5f1bf796aa..ccb7446d86 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -2,7 +2,24 @@ import { CommonModule, DatePipe } from "@angular/common"; import { NgModule } from "@angular/core"; import { FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { AutofocusDirective, ToastModule } from "@bitwarden/components"; +import { + AddAccountCreditDialogComponent, + ManageTaxInformationComponent, + SelectPaymentMethodComponent, + VerifyBankAccountComponent, +} from "@bitwarden/angular/billing/components"; +import { + AsyncActionsModule, + AutofocusDirective, + ButtonModule, + CheckboxModule, + DialogModule, + FormFieldModule, + RadioButtonModule, + SelectModule, + ToastModule, + TypographyModule, +} from "@bitwarden/components"; import { CalloutComponent } from "./components/callout.component"; import { A11yInvalidDirective } from "./directives/a11y-invalid.directive"; @@ -41,6 +58,14 @@ import { IconComponent } from "./vault/components/icon.component"; CommonModule, FormsModule, ReactiveFormsModule, + AsyncActionsModule, + RadioButtonModule, + FormFieldModule, + SelectModule, + ButtonModule, + CheckboxModule, + DialogModule, + TypographyModule, ], declarations: [ A11yInvalidDirective, @@ -70,6 +95,10 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, + AddAccountCreditDialogComponent, + ManageTaxInformationComponent, + SelectPaymentMethodComponent, + VerifyBankAccountComponent, ], exports: [ A11yInvalidDirective, @@ -100,6 +129,10 @@ import { IconComponent } from "./vault/components/icon.component"; UserTypePipe, IfFeatureDirective, FingerprintPipe, + AddAccountCreditDialogComponent, + ManageTaxInformationComponent, + SelectPaymentMethodComponent, + VerifyBankAccountComponent, ], providers: [ CreditCardNumberPipe, diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 17a98498d6..40405b062c 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -1,6 +1,7 @@ import { InjectionToken } from "@angular/core"; import { Observable, Subject } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; import { ClientType } from "@bitwarden/common/enums"; import { AbstractStorageService, @@ -36,7 +37,7 @@ export const MEMORY_STORAGE = new SafeInjectionToken("ME export const SECURE_STORAGE = new SafeInjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new SafeInjectionToken("STATE_FACTORY"); export const LOGOUT_CALLBACK = new SafeInjectionToken< - (expired: boolean, userId?: string) => Promise + (logoutReason: LogoutReason, userId?: string) => Promise >("LOGOUT_CALLBACK"); export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise>( "LOCKED_CALLBACK", @@ -53,3 +54,7 @@ export const INTRAPROCESS_MESSAGING_SUBJECT = new SafeInjectionToken< Subject>> >("INTRAPROCESS_MESSAGING_SUBJECT"); export const CLIENT_TYPE = new SafeInjectionToken("CLIENT_TYPE"); + +export const REFRESH_ACCESS_TOKEN_ERROR_CALLBACK = new SafeInjectionToken<() => void>( + "REFRESH_ACCESS_TOKEN_ERROR_CALLBACK", +); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 60f83934af..048c182900 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -13,6 +13,7 @@ import { InternalUserDecryptionOptionsServiceAbstraction, UserDecryptionOptionsService, UserDecryptionOptionsServiceAbstraction, + LogoutReason, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -109,14 +110,20 @@ import { DomainSettingsService, DefaultDomainSettingsService, } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + BillingApiServiceAbstraction, + BraintreeServiceAbstraction, + OrganizationBillingServiceAbstraction, + PaymentMethodWarningsServiceAbstraction, + StripeServiceAbstraction, +} from "@bitwarden/common/billing/abstractions"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; -import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { PaymentMethodWarningsServiceAbstraction } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services/payment-method-warnings.service"; +import { BraintreeService } from "@bitwarden/common/billing/services/payment-processors/braintree.service"; +import { StripeService } from "@bitwarden/common/billing/services/payment-processors/stripe.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; @@ -232,6 +239,7 @@ import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync- import { SyncService } from "@bitwarden/common/vault/services/sync/sync.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service"; +import { ToastService } from "@bitwarden/components"; import { ImportApiService, ImportApiServiceAbstraction, @@ -275,6 +283,7 @@ import { DEFAULT_VAULT_TIMEOUT, INTRAPROCESS_MESSAGING_SUBJECT, CLIENT_TYPE, + REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, } from "./injection-tokens"; import { ModalService } from "./modal.service"; @@ -316,8 +325,12 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LOGOUT_CALLBACK, useFactory: - (messagingService: MessagingServiceAbstraction) => (expired: boolean, userId?: string) => - Promise.resolve(messagingService.send("logout", { expired: expired, userId: userId })), + (messagingService: MessagingServiceAbstraction) => + async (logoutReason: LogoutReason, userId?: string) => { + return Promise.resolve( + messagingService.send("logout", { logoutReason: logoutReason, userId: userId }), + ); + }, deps: [MessagingServiceAbstraction], }), safeProvider({ @@ -526,6 +539,7 @@ const safeProviders: SafeProvider[] = [ KeyGenerationServiceAbstraction, EncryptService, LogService, + LOGOUT_CALLBACK, ], }), safeProvider({ @@ -579,6 +593,17 @@ const safeProviders: SafeProvider[] = [ StateProvider, ], }), + safeProvider({ + provide: REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + useFactory: (toastService: ToastService, i18nService: I18nServiceAbstraction) => () => { + toastService.showToast({ + variant: "error", + title: i18nService.t("errorRefreshingAccessToken"), + message: i18nService.t("errorRefreshingAccessTokenDesc"), + }); + }, + deps: [ToastService, I18nServiceAbstraction], + }), safeProvider({ provide: ApiServiceAbstraction, useClass: ApiService, @@ -587,8 +612,10 @@ const safeProviders: SafeProvider[] = [ PlatformUtilsServiceAbstraction, EnvironmentService, AppIdServiceAbstraction, - VaultTimeoutSettingsServiceAbstraction, + REFRESH_ACCESS_TOKEN_ERROR_CALLBACK, + LogService, LOGOUT_CALLBACK, + VaultTimeoutSettingsServiceAbstraction, ], }), safeProvider({ @@ -1190,6 +1217,16 @@ const safeProviders: SafeProvider[] = [ useClass: KdfConfigService, deps: [StateProvider], }), + safeProvider({ + provide: BraintreeServiceAbstraction, + useClass: BraintreeService, + deps: [LogService], + }), + safeProvider({ + provide: StripeServiceAbstraction, + useClass: StripeService, + deps: [LogService], + }), ]; function encryptServiceFactory( diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 4b593d336e..bf5edbda82 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -15,7 +15,7 @@
diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html index b4dad835ee..9785bf05ab 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html @@ -8,6 +8,7 @@ [label]="regionConfig.domain" > diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts index f01873dd3e..fe41f0a3ac 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.ts @@ -1,17 +1,26 @@ import { CommonModule } from "@angular/common"; import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; -import { EMPTY, Subject, from, map, of, switchMap, takeUntil, tap } from "rxjs"; +import { Subject, from, map, of, pairwise, startWith, switchMap, takeUntil, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/common/enums"; import { Environment, EnvironmentService, Region, RegionConfig, } from "@bitwarden/common/platform/abstractions/environment.service"; -import { FormFieldModule, SelectModule } from "@bitwarden/components"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService, FormFieldModule, SelectModule, ToastService } from "@bitwarden/components"; +import { RegistrationSelfHostedEnvConfigDialogComponent } from "./registration-self-hosted-env-config-dialog.component"; + +/** + * Component for selecting the environment to register with in the email verification registration flow. + * Outputs the selected region to the parent component so it can respond as necessary. + */ @Component({ standalone: true, selector: "auth-registration-env-selector", @@ -19,7 +28,7 @@ import { FormFieldModule, SelectModule } from "@bitwarden/components"; imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule], }) export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { - @Output() onOpenSelfHostedSettings = new EventEmitter(); + @Output() selectedRegionChange = new EventEmitter(); ServerEnvironmentType = Region; @@ -33,12 +42,24 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { availableRegionConfigs: RegionConfig[] = this.environmentService.availableRegions(); + private selectedRegionFromEnv: RegionConfig | Region.SelfHosted; + + isDesktopOrBrowserExtension = false; + private destroy$ = new Subject(); constructor( private formBuilder: FormBuilder, private environmentService: EnvironmentService, - ) {} + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private platformUtilsService: PlatformUtilsService, + ) { + const clientType = platformUtilsService.getClientType(); + this.isDesktopOrBrowserExtension = + clientType === ClientType.Desktop || clientType === ClientType.Browser; + } async ngOnInit() { await this.initSelectedRegionAndListenForEnvChanges(); @@ -61,13 +82,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { return regionConfig; }), - tap((selectedRegionInitialValue: RegionConfig | Region.SelfHosted) => { - // This inits the form control with the selected region, but - // it also sets the value to self hosted if the self hosted settings are saved successfully - // in the client specific implementation managed by the parent component. - // It also resets the value to the previously selected region if the self hosted - // settings are closed without saving. We don't emit the event to avoid a loop. - this.selectedRegion.setValue(selectedRegionInitialValue, { emitEvent: false }); + tap((selectedRegionFromEnv: RegionConfig | Region.SelfHosted) => { + // Only set the value if it is different from the current value. + if (selectedRegionFromEnv !== this.selectedRegion.value) { + // Don't emit to avoid triggering the selectedRegion valueChanges subscription + // which could loop back to this code. + this.selectedRegion.setValue(selectedRegionFromEnv, { emitEvent: false }); + } + + // Save this off so we can reset the value to the previously selected region + // if the self hosted settings are closed without saving. + this.selectedRegionFromEnv = selectedRegionFromEnv; }), takeUntil(this.destroy$), ) @@ -77,23 +102,66 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { private listenForSelectedRegionChanges() { this.selectedRegion.valueChanges .pipe( - switchMap((selectedRegionConfig: RegionConfig | Region.SelfHosted | null) => { - if (selectedRegionConfig === null) { - return of(null); - } + startWith(null), // required so that first user choice is not ignored + pairwise(), + switchMap( + ([prevSelectedRegion, selectedRegion]: [ + RegionConfig | Region.SelfHosted | null, + RegionConfig | Region.SelfHosted | null, + ]) => { + if (selectedRegion === null) { + this.selectedRegionChange.emit(selectedRegion); + return of(null); + } - if (selectedRegionConfig === Region.SelfHosted) { - this.onOpenSelfHostedSettings.emit(); - return EMPTY; - } + if (selectedRegion === Region.SelfHosted) { + return from( + RegistrationSelfHostedEnvConfigDialogComponent.open(this.dialogService), + ).pipe( + tap((result: boolean | undefined) => + this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion), + ), + ); + } - return from(this.environmentService.setEnvironment(selectedRegionConfig.key)); - }), + this.selectedRegionChange.emit(selectedRegion); + return from(this.environmentService.setEnvironment(selectedRegion.key)); + }, + ), takeUntil(this.destroy$), ) .subscribe(); } + private handleSelfHostedEnvConfigDialogResult( + result: boolean | undefined, + prevSelectedRegion: RegionConfig | Region.SelfHosted | null, + ) { + if (result === true) { + this.selectedRegionChange.emit(Region.SelfHosted); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("environmentSaved"), + }); + return; + } + + // Reset the value to the previously selected region or the current env setting + // if the self hosted env settings dialog is closed without saving. + if ( + (result === false || result === undefined) && + prevSelectedRegion !== null && + prevSelectedRegion !== Region.SelfHosted + ) { + this.selectedRegionChange.emit(prevSelectedRegion); + this.selectedRegion.setValue(prevSelectedRegion, { emitEvent: false }); + } else { + this.selectedRegionChange.emit(this.selectedRegionFromEnv); + this.selectedRegion.setValue(this.selectedRegionFromEnv, { emitEvent: false }); + } + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.html new file mode 100644 index 0000000000..92c2f9f2f7 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.html @@ -0,0 +1,107 @@ +
+ + Self-hosted environment + + + {{ "baseUrl" | i18n }} + + {{ "selfHostedBaseUrlHint" | i18n }} + + + + + +

+ {{ "selfHostedCustomEnvHeader" | i18n }} +

+ + + {{ "webVaultUrl" | i18n }} + + + + + {{ "apiUrl" | i18n }} + + + + + {{ "identityUrl" | i18n }} + + + + + {{ "notificationsUrl" | i18n }} + + + + + {{ "iconsUrl" | i18n }} + + +
+ + + {{ "selfHostedEnvFormInvalid" | i18n }} + +
+ + + + + +
+
diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.ts b/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.ts new file mode 100644 index 0000000000..2bedb4b358 --- /dev/null +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-self-hosted-env-config-dialog.component.ts @@ -0,0 +1,164 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, +} from "@angular/forms"; +import { Subject, firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogService, + FormFieldModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; + +/** + * Validator for self-hosted environment settings form. + * It enforces that at least one URL is provided. + */ +function selfHostedEnvSettingsFormValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const formGroup = control as FormGroup; + const baseUrl = formGroup.get("baseUrl")?.value; + const webVaultUrl = formGroup.get("webVaultUrl")?.value; + const apiUrl = formGroup.get("apiUrl")?.value; + const identityUrl = formGroup.get("identityUrl")?.value; + const iconsUrl = formGroup.get("iconsUrl")?.value; + const notificationsUrl = formGroup.get("notificationsUrl")?.value; + + if (baseUrl || webVaultUrl || apiUrl || identityUrl || iconsUrl || notificationsUrl) { + return null; // valid + } else { + return { atLeastOneUrlIsRequired: true }; // invalid + } + }; +} + +/** + * Dialog for configuring self-hosted environment settings. + */ +@Component({ + standalone: true, + selector: "auth-registration-self-hosted-env-config-dialog", + templateUrl: "registration-self-hosted-env-config-dialog.component.html", + imports: [ + CommonModule, + JslibModule, + DialogModule, + ButtonModule, + LinkModule, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + AsyncActionsModule, + ], +}) +export class RegistrationSelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy { + /** + * Opens the dialog. + * @param dialogService - Dialog service. + * @returns Promise that resolves to true if the dialog was closed with a successful result, false otherwise. + */ + static async open(dialogService: DialogService): Promise { + const dialogRef = dialogService.open(RegistrationSelfHostedEnvConfigDialogComponent, { + disableClose: false, + }); + + const dialogResult = await firstValueFrom(dialogRef.closed); + + return dialogResult; + } + + formGroup = this.formBuilder.group( + { + baseUrl: [null], + webVaultUrl: [null], + apiUrl: [null], + identityUrl: [null], + iconsUrl: [null], + notificationsUrl: [null], + }, + { validators: selfHostedEnvSettingsFormValidator() }, + ); + + get baseUrl(): FormControl { + return this.formGroup.get("baseUrl") as FormControl; + } + + get webVaultUrl(): FormControl { + return this.formGroup.get("webVaultUrl") as FormControl; + } + + get apiUrl(): FormControl { + return this.formGroup.get("apiUrl") as FormControl; + } + + get identityUrl(): FormControl { + return this.formGroup.get("identityUrl") as FormControl; + } + + get iconsUrl(): FormControl { + return this.formGroup.get("iconsUrl") as FormControl; + } + + get notificationsUrl(): FormControl { + return this.formGroup.get("notificationsUrl") as FormControl; + } + + showCustomEnv = false; + showErrorSummary = false; + + private destroy$ = new Subject(); + + constructor( + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private environmentService: EnvironmentService, + ) {} + + ngOnInit() {} + + submit = async () => { + this.showErrorSummary = false; + + if (this.formGroup.invalid) { + this.showErrorSummary = true; + return; + } + + await this.environmentService.setEnvironment(Region.SelfHosted, { + base: this.baseUrl.value, + api: this.apiUrl.value, + identity: this.identityUrl.value, + webVault: this.webVaultUrl.value, + icons: this.iconsUrl.value, + notifications: this.notificationsUrl.value, + }); + + this.dialogRef.close(true); + }; + + async cancel() { + this.dialogRef.close(false); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.component.html b/libs/auth/src/angular/registration/registration-start/registration-start.component.html index 8f64232f9c..8da2eb76b5 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.component.html +++ b/libs/auth/src/angular/registration/registration-start/registration-start.component.html @@ -1,5 +1,9 @@
+ + {{ "emailAddress" | i18n }} +Note that the self hosted option is not present in the environment selector. -### Self Hosted Example +### US Region - + -### Query Param Example +### EU Region + + + +### Query Params The component accepts two query parameters: `email` and `emailReadonly`. If an email is provided, it will be pre-filled in the email input field. If `emailReadonly` is set to `true`, the email input field will be set to readonly. `emailReadonly` is primarily for the organization invite flow. - + + +## Desktop + +Behavior to note: + +- The self hosted option is present in the environment selector. +- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox + will disappear. + +### US Region + + + +### EU Region + + + +### Self Hosted + +Note the fact that the terms of service and privacy policy checkbox is not present when the +environment is self hosted. + + + +## Browser Extension + +Behavior to note: + +- The self hosted option is present in the environment selector. +- If you go from non-self hosted to self hosted, the terms of service and privacy policy checkbox + will disappear. + +### US Region + + + +### EU Region + + + +### Self Hosted + +Note the fact that the terms of service and privacy policy checkbox is not present when the +environment is self hosted. + + diff --git a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts index 099f086b96..50d1f15182 100644 --- a/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts +++ b/libs/auth/src/angular/registration/registration-start/registration-start.stories.ts @@ -1,10 +1,30 @@ import { importProvidersFrom } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { ActivatedRoute, Params } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; import { of } from "rxjs"; +import { ClientType } from "@bitwarden/common/enums"; +import { + Environment, + EnvironmentService, + Region, + Urls, +} from "@bitwarden/common/platform/abstractions/environment.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + FormFieldModule, + LinkModule, + SelectModule, + ToastOptions, + ToastService, + TypographyModule, +} from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../../../apps/web/src/app/core/tests"; @@ -15,52 +35,70 @@ export default { component: RegistrationStartComponent, } as Meta; -const decorators = (options: { isSelfHost: boolean; queryParams: Params }) => { +const decorators = (options: { + isSelfHost?: boolean; + queryParams?: Params; + clientType?: ClientType; + defaultRegion?: Region; +}) => { return [ moduleMetadata({ - imports: [RouterTestingModule], + imports: [ + RouterTestingModule, + DialogModule, + ReactiveFormsModule, + FormFieldModule, + SelectModule, + ButtonModule, + LinkModule, + TypographyModule, + AsyncActionsModule, + BrowserAnimationsModule, + ], providers: [ { provide: ActivatedRoute, - useValue: { queryParams: of(options.queryParams) }, - }, - { - provide: PlatformUtilsService, - useValue: { - isSelfHost: () => options.isSelfHost, - } as Partial, + useValue: { queryParams: of(options.queryParams || {}) }, }, ], }), applicationConfig({ - providers: [importProvidersFrom(PreloadedEnglishI18nModule)], + providers: [ + importProvidersFrom(PreloadedEnglishI18nModule), + { + provide: EnvironmentService, + useValue: { + environment$: of({ + getRegion: () => options.defaultRegion || Region.US, + } as Partial), + availableRegions: () => [ + { key: Region.US, domain: "bitwarden.com", urls: {} }, + { key: Region.EU, domain: "bitwarden.eu", urls: {} }, + ], + setEnvironment: (region: Region, urls?: Urls) => Promise.resolve({}), + } as Partial, + }, + { + provide: PlatformUtilsService, + useValue: { + isSelfHost: () => options.isSelfHost || false, + getClientType: () => options.clientType || ClientType.Web, + } as Partial, + }, + { + provide: ToastService, + useValue: { + showToast: (options: ToastOptions) => {}, + } as Partial, + }, + ], }), ]; }; type Story = StoryObj; -export const CloudExample: Story = { - render: (args) => ({ - props: args, - template: ` - - `, - }), - decorators: decorators({ isSelfHost: false, queryParams: {} }), -}; - -export const SelfHostExample: Story = { - render: (args) => ({ - props: args, - template: ` - - `, - }), - decorators: decorators({ isSelfHost: true, queryParams: {} }), -}; - -export const QueryParamsExample: Story = { +export const WebUSRegionExample: Story = { render: (args) => ({ props: args, template: ` @@ -68,7 +106,120 @@ export const QueryParamsExample: Story = { `, }), decorators: decorators({ - isSelfHost: false, + clientType: ClientType.Web, + queryParams: {}, + defaultRegion: Region.US, + }), +}; + +export const WebEURegionExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Web, + queryParams: {}, + defaultRegion: Region.EU, + }), +}; + +export const WebUSRegionQueryParamsExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Web, + defaultRegion: Region.US, queryParams: { email: "jaredWasHere@bitwarden.com", emailReadonly: "true" }, }), }; + +export const DesktopUSRegionExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Desktop, + defaultRegion: Region.US, + isSelfHost: false, + }), +}; + +export const DesktopEURegionExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Desktop, + defaultRegion: Region.EU, + isSelfHost: false, + }), +}; + +export const DesktopSelfHostExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Desktop, + isSelfHost: true, + defaultRegion: Region.SelfHosted, + }), +}; + +export const BrowserExtensionUSRegionExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Browser, + defaultRegion: Region.US, + isSelfHost: false, + }), +}; + +export const BrowserExtensionEURegionExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Browser, + defaultRegion: Region.EU, + isSelfHost: false, + }), +}; + +export const BrowserExtensionSelfHostExample: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + decorators: decorators({ + clientType: ClientType.Browser, + isSelfHost: true, + defaultRegion: Region.SelfHosted, + }), +}; diff --git a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts index 8cb40d9452..8c62136d63 100644 --- a/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts +++ b/libs/auth/src/angular/user-verification/user-verification-form-input.component.ts @@ -197,8 +197,8 @@ export class UserVerificationFormInputComponent implements ControlValueAccessor, } } - // Don't bother executing secret changes if biometrics verification is active. - if (this.activeClientVerificationOption === ActiveClientVerificationOption.Biometrics) { + // Executing secret changes for all non biometrics verification. Biometrics doesn't have a user entered secret. + if (this.activeClientVerificationOption !== ActiveClientVerificationOption.Biometrics) { this.processSecretChanges(this.secret.value); } diff --git a/libs/auth/src/common/index.ts b/libs/auth/src/common/index.ts index 936666e1a8..43efd7c638 100644 --- a/libs/auth/src/common/index.ts +++ b/libs/auth/src/common/index.ts @@ -3,5 +3,6 @@ */ export * from "./abstractions"; export * from "./models"; +export * from "./types"; export * from "./services"; export * from "./utilities"; diff --git a/libs/auth/src/common/types/index.ts b/libs/auth/src/common/types/index.ts new file mode 100644 index 0000000000..37ec426fb6 --- /dev/null +++ b/libs/auth/src/common/types/index.ts @@ -0,0 +1 @@ +export * from "./logout-reason.type"; diff --git a/libs/auth/src/common/types/logout-reason.type.ts b/libs/auth/src/common/types/logout-reason.type.ts new file mode 100644 index 0000000000..71fff51064 --- /dev/null +++ b/libs/auth/src/common/types/logout-reason.type.ts @@ -0,0 +1,10 @@ +export type LogoutReason = + | "invalidGrantError" + | "vaultTimeout" + | "invalidSecurityStamp" + | "logoutNotification" + | "keyConnectorError" + | "sessionExpired" + | "accessTokenUnableToBeDecrypted" + | "refreshTokenSecureStorageRetrievalFailure" + | "accountDeleted"; diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index d078051f64..a88dfbb278 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -70,16 +70,16 @@ export abstract class TokenService { /** * Gets the access token * @param userId - The optional user id to get the access token for; if not provided, the active user is used. - * @returns A promise that resolves with the access token or undefined. + * @returns A promise that resolves with the access token or null. */ - getAccessToken: (userId?: UserId) => Promise; + getAccessToken: (userId?: UserId) => Promise; /** * Gets the refresh token. * @param userId - The optional user id to get the refresh token for; if not provided, the active user is used. - * @returns A promise that resolves with the refresh token or undefined. + * @returns A promise that resolves with the refresh token or null. */ - getRefreshToken: (userId?: UserId) => Promise; + getRefreshToken: (userId?: UserId) => Promise; /** * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index 65d1030bd3..6b81844afb 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -1,5 +1,7 @@ import { firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; + import { ApiService } from "../../abstractions/api.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../admin-console/enums"; @@ -57,7 +59,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private logService: LogService, private organizationService: OrganizationService, private keyGenerationService: KeyGenerationService, - private logoutCallback: (expired: boolean, userId?: string) => Promise, + private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise, private stateProvider: StateProvider, ) { this.usesKeyConnectorState = this.stateProvider.getActive(USES_KEY_CONNECTOR); @@ -192,7 +194,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { if (this.logoutCallback != null) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.logoutCallback(false); + this.logoutCallback("keyConnectorError"); } throw new Error("Key Connector error"); } diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index 9c5dd9fc91..d7a4c52716 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -1,6 +1,8 @@ import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom } from "rxjs"; +import { LogoutReason } from "@bitwarden/auth/common"; + import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; @@ -9,11 +11,18 @@ import { LogService } from "../../platform/abstractions/log.service"; import { AbstractStorageService } from "../../platform/abstractions/storage.service"; import { StorageLocation } from "../../platform/enums"; import { StorageOptions } from "../../platform/models/domain/storage-options"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; import { UserId } from "../../types/guid"; import { VaultTimeout, VaultTimeoutStringType } from "../../types/vault-timeout.type"; import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service"; -import { DecodedAccessToken, TokenService, TokenStorageLocation } from "./token.service"; +import { + AccessTokenKey, + DecodedAccessToken, + TokenService, + TokenStorageLocation, +} from "./token.service"; import { ACCESS_TOKEN_DISK, ACCESS_TOKEN_MEMORY, @@ -36,6 +45,7 @@ describe("TokenService", () => { let keyGenerationService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; + let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: string]>; const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut; const memoryVaultTimeout: VaultTimeout = 30; @@ -46,6 +56,9 @@ describe("TokenService", () => { const accessTokenJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0IiwibmJmIjoxNzA5MzI0MTExLCJpYXQiOjE3MDkzMjQxMTEsImV4cCI6MTcwOTMyNzcxMSwic2NvcGUiOlsiYXBpIiwib2ZmbGluZV9hY2Nlc3MiXSwiYW1yIjpbIkFwcGxpY2F0aW9uIl0sImNsaWVudF9pZCI6IndlYiIsInN1YiI6ImVjZTcwYTEzLTcyMTYtNDNjNC05OTc3LWIxMDMwMTQ2ZTFlNyIsImF1dGhfdGltZSI6MTcwOTMyNDEwNCwiaWRwIjoiYml0d2FyZGVuIiwicHJlbWl1bSI6ZmFsc2UsImVtYWlsIjoiZXhhbXBsZUBiaXR3YXJkZW4uY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJzc3RhbXAiOiJHWTdKQU82NENLS1RLQkI2WkVBVVlMMldPUVU3QVNUMiIsIm5hbWUiOiJUZXN0IFVzZXIiLCJvcmdvd25lciI6WyI5MmI0OTkwOC1iNTE0LTQ1YTgtYmFkYi1iMTAzMDE0OGZlNTMiLCIzOGVkZTMyMi1iNGI0LTRiZDgtOWUwOS1iMTA3MDExMmRjMTEiLCJiMmQwNzAyOC1hNTgzLTRjM2UtOGQ2MC1iMTA3MDExOThjMjkiLCJiZjkzNGJhMi0wZmQ0LTQ5ZjItYTk1ZS1iMTA3MDExZmM5ZTYiLCJjMGI3Zjc1ZC0wMTVmLTQyYzktYjNhNi1iMTA4MDE3NjA3Y2EiXSwiZGV2aWNlIjoiNGI4NzIzNjctMGRhNi00MWEwLWFkY2ItNzdmMmZlZWZjNGY0IiwianRpIjoiNzUxNjFCRTQxMzFGRjVBMkRFNTExQjhDNEUyRkY4OUEifQ.n7roP8sSbfwcYdvRxZNZds27IK32TW6anorE6BORx_Q"; + const encryptedAccessToken = + "2.rFNYSTJoljn8h6GOSNVYdQ==|4dIp7ONJzC+Kx1ClA+1aIAb7EqCQ4OjnADCYdCPg7BKkdheG+yM62ZiONFk+S6at84M+RnGWWO04aIjinTdJhlhyUmszePNATxIfX60Y+bFKQhlMuCtZpYdEmQDzXVgT43YRbf/6NnN9WzhefLqeMiocwoIJTEpLptb+Zcm7T3MJpkX4dR9w5LUOxUTNFEGd5PlWaI8FBavOkNsrzY5skRK70pvFABET5IDeRlKhi8NwbzvTzkO3SisLRzih+djiz5nEZf0+ujeGAp6P+o7l0mB0sXVsNJzcuE4S9QtHLnx31N6z3mQm5pOgP4EmEOdRIcQGc1p7dL1vXcXtaTJLtfKXoJjJbYT3wplnY9Pf8+2FVxdbM3bRB2yVsnEzgLcf9UchKThQSdOy8+5TO/prDbUt5mDpO4GmRltom5ncda8yJaD3Hw1DO7fa0Xh+kfeByxb1AwBC+GTPfqmo5uqr0J4dZsf9cGlPMTElwR3GYmD60OcQ6iDX36CZZjqqJqBwKSpepDXV39p9G347e6YAAvJenLDKtdjgfWXCMXbkwETbMgYooFDRd60KYsGIXV16UwzJSvczgTY2d+hYb2Cl0lClequaiwcRxLVtW2xau6qoEPjTqJjJi9I0Cs2WNL4LRH96Ir14a3bEtnTvkO1NjN+bQNon+KksaP2BqTbuiAfZbBP/cL4S1Oew4G00PSLZUGV5S1BI0ooJy6e2NLQJlYqfCeKM6RgpvgfOiXlZddVgkkB6lohLjyVvcSZNuKPjs1wZMZ9C76bKb6o39NFK8G3/YScELFf9gkueWjmhcjrs22+xNDn5rxXeedwIkVW9UJVNLc//eGxLfp70y8fNDcyTPRN1UUpqT8+wSz+9ZHl4DLUK0DE2jIveEDke8vi4MK/XLMC/c50rr1NCEuVy6iA3nwiOzVo/GNfeKTpzMcR/D9A0gxkC9GyZ3riSsMQsGNXhZCZLdsFYp0gLiiJxVilMUfyTWaygsNm87GPY3ep3GEHcq/pCuxrpLQQYT3V1j95WJvFxb8dSLiPHb8STR0GOZhe7SquI5LIRmYCFTo+3VBnItYeuin9i2xCIqWz886xIyllHN2BIPILbA1lCOsCsz1BRRGNqtLvmTeVRO8iujsHWBJicVgSI7/dgSJwcdOv2t4TIVtnN1hJkQnz+HZcJ2FYK/VWlo4UQYYoML52sBd1sSz/n8/8hrO2N4X9frHHNCrcxeoyChTKo2cm4rAxHylLbCZYvGt/KIW9x3AFkPBMr7tAc3yq98J0Crna8ukXc3F3uGb5QXLnBi//3zBDN6RCv7ByaFW5G0I+pglBegzeFBqKH8xwfy76B2e2VLFF8rz/r/wQzlumGFypsRhAoGxrkZyzjec/k+RNR0arf7TTX7ymC1cueTnItRDx89veW6WLlF53NpAGqC8GJSp4T2FGIIk01y29j6Ji7GOlQ8BUbyLWYjMfHf3khRzAfr6UC2QgVvKWQTKET4Y/b1nZCnwxeW8wC80GHtYGuarsU+KlsEw4242cjyIN1GobrWaA2GTOedQDEMWUA64McAw5fAvMEEao5DM7i57tMzJHeKfruyMuXYQkBca094vmATjJ/T+kIrWGIcmxCT/Fp2SW1hcxr6Ciwuog84LVfbVlUl2MAj3eC/xqL/5HP6Q3ObD0ld444GV+HSrQUqfIvEIn9gFmalW6TGugyhfROACCogoXbeIr1AyMUNDnl4EWlPl6u7SQvPX+itKyq4qhaK2J0W6f7ElLVQ5GbC2uwARuhXOi7mqEZ5FP0V675C5NPZOl2ZEd6BhmuyhGkmQEtEvw0DCKnbKM7bKMk90Y599DSnuEna4BNFBVjJ7k+BuNhXUKO+iNcDZT0pCQhOKRVLWsaqVff3BsuQ4zMEOVnccJwwAVipwSRyxZi8bF+Wyun6BVI8pz1CBvRMy+6ifmIq2awEL8NnV65hF2jyZDEVwsnrvCyT7MlM8l5C3MhqH/MgMcKqOsUz+P6Jv5sBi4WvojsaHzqxQ6miBHpHhGDpYH5K53LVs36henB/tOUTcg5ZnO4ZM67jjB7Oz7to+QnJsldp5Bdwvi1XD/4jeh/Llezu5/KwwytSHnZG1z6dZA7B8rKwnI+yN2Qnfi70h68jzGZ1xCOFPz9KMorNKP3XLw8x2g9H6lEBXdV95uc/TNw+WTJbvKRawns/DZhM1u/g13lU6JG19cht3dh/DlKRcJpj1AdOAxPiUubTSkhBmdwRj2BTTHrVlF3/9ladTP4s4f6Zj9TtQvR9CREVe7CboGflxDYC+Jww3PU50XLmxQjkuV5MkDAmBVcyFCFOcHhDRoxet4FX9ec0wjNeDpYtkI8B/qUS1Rp+is1jOxr4/ni|pabwMkF/SdYKdDlow4uKxaObrAP0urmv7N7fA9bedec="; + const accessTokenDecoded: DecodedAccessToken = { iss: "http://localhost", nbf: 1709324111, @@ -93,6 +106,7 @@ describe("TokenService", () => { keyGenerationService = mock(); encryptService = mock(); logService = mock(); + logoutCallback = jest.fn(); const supportsSecureStorage = false; // default to false; tests will override as needed tokenService = createTokenService(supportsSecureStorage); @@ -152,7 +166,7 @@ describe("TokenService", () => { expect(result).toEqual(true); }); - it("should return false if no access token exists in memory, disk, or secure storage", async () => { + it("returns false when no access token exists in memory, disk, or secure storage", async () => { // Act const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); @@ -162,7 +176,7 @@ describe("TokenService", () => { }); describe("setAccessToken", () => { - it("should throw an error if the access token is null", async () => { + it("throws an error when the access token is null", async () => { // Act const result = tokenService.setAccessToken( null, @@ -173,7 +187,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Access token is required."); }); - it("should throw an error if an invalid token is passed in", async () => { + it("throws an error when an invalid token is passed in", async () => { // Act const result = tokenService.setAccessToken( "invalidToken", @@ -216,7 +230,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should set the access token in memory", async () => { + it("set the access token in memory", async () => { // Act await tokenService.setAccessToken( accessTokenJwt, @@ -246,6 +260,14 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage supported on platform)", () => { + const accessTokenKey = new SymmetricCryptoKey( + new Uint8Array(64) as CsprngArray, + ) as AccessTokenKey; + + const accessTokenKeyB64 = { + keyB64: + "lI7lSoejJ1HsrTkRs2Ipm0x+YcZMKpgm7WQGCNjAWmFAyGOKossXwBJvvtbxcYDZ0G0XNY8Gp7DBXZV2tWAO5w==", + }; beforeEach(() => { const supportsSecureStorage = true; tokenService = createTokenService(supportsSecureStorage); @@ -259,7 +281,7 @@ describe("TokenService", () => { .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); - keyGenerationService.createKey.mockResolvedValue("accessTokenKey" as any); + keyGenerationService.createKey.mockResolvedValue(accessTokenKey); const mockEncryptedAccessToken = "encryptedAccessToken"; @@ -267,6 +289,11 @@ describe("TokenService", () => { encryptedString: mockEncryptedAccessToken, } as any); + // First call resolves to null to simulate no key in secure storage + // then resolves to the key to simulate the key being set in secure storage + // and retrieved successfully to ensure it was set. + secureStorageService.get.mockResolvedValueOnce(null).mockResolvedValue(accessTokenKeyB64); + // Act await tokenService.setAccessToken( accessTokenJwt, @@ -278,7 +305,7 @@ describe("TokenService", () => { // assert that the AccessTokenKey was set in secure storage expect(secureStorageService.save).toHaveBeenCalledWith( accessTokenKeySecureStorageKey, - "accessTokenKey", + accessTokenKey, secureStorageOptions, ); @@ -292,18 +319,85 @@ describe("TokenService", () => { singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock, ).toHaveBeenCalledWith(null); }); + + it("should fallback to disk storage for the access token if the access token cannot be set in secure storage", async () => { + // This tests the scenario where the access token key silently fails to be set in secure storage + + // Arrange: + keyGenerationService.createKey.mockResolvedValue(accessTokenKey); + + // First call resolves to null to simulate no key in secure storage + // and then resolves to no key after it should have been set + secureStorageService.get.mockResolvedValueOnce(null).mockResolvedValue(null); + + // Act + await tokenService.setAccessToken( + accessTokenJwt, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + // Assert + + // assert that we tried to store the AccessTokenKey in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + accessTokenKeySecureStorageKey, + accessTokenKey, + secureStorageOptions, + ); + + // assert that we logged the error + expect(logService.error).toHaveBeenCalledWith( + "SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.", + new Error("New Access token key unable to be retrieved from secure storage."), + ); + + // assert that the access token was put on disk unencrypted + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(accessTokenJwt); + }); + + it("should fallback to disk storage for the access token if secure storage errors on trying to get an existing access token key", async () => { + // This tests the scenario for linux users who don't have secure storage configured. + + // Arrange: + keyGenerationService.createKey.mockResolvedValue(accessTokenKey); + + // Mock linux secure storage error + const secureStorageError = "Secure storage error"; + secureStorageService.get.mockRejectedValue(new Error(secureStorageError)); + + // Act + await tokenService.setAccessToken( + accessTokenJwt, + diskVaultTimeoutAction, + diskVaultTimeout, + ); + // Assert + + // assert that we logged the error + expect(logService.error).toHaveBeenCalledWith( + "SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.", + new Error(secureStorageError), + ); + + // assert that the access token was put on disk unencrypted + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(accessTokenJwt); + }); }); }); describe("getAccessToken", () => { - it("should return undefined if no user id is provided and there is no active user in global state", async () => { + it("returns null when no user id is provided and there is no active user in global state", async () => { // Act const result = await tokenService.getAccessToken(); // Assert - expect(result).toBeUndefined(); + expect(result).toBeNull(); }); - it("should return null if no access token is found in memory, disk, or secure storage", async () => { + it("returns null when no access token is found in memory, disk, or secure storage", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -317,11 +411,8 @@ describe("TokenService", () => { describe("Memory storage tests", () => { test.each([ - [ - "should get the access token from memory for the provided user id", - userIdFromAccessToken, - ], - ["should get the access token from memory with no user id provided", undefined], + ["gets the access token from memory when a user id is provided ", userIdFromAccessToken], + ["gets the access token from memory when no user id is provided", undefined], ])("%s", async (_, userId) => { // Arrange singleUserStateProvider @@ -350,11 +441,8 @@ describe("TokenService", () => { describe("Disk storage tests (secure storage not supported on platform)", () => { test.each([ - [ - "should get the access token from disk for the specified user id", - userIdFromAccessToken, - ], - ["should get the access token from disk with no user id specified", undefined], + ["gets the access token from disk when the user id is specified", userIdFromAccessToken], + ["gets the access token from disk when no user id is specified", undefined], ])("%s", async (_, userId) => { // Arrange singleUserStateProvider @@ -387,11 +475,11 @@ describe("TokenService", () => { test.each([ [ - "should get the encrypted access token from disk, decrypt it, and return it when user id is provided", + "gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", userIdFromAccessToken, ], [ - "should get the encrypted access token from disk, decrypt it, and return it when no user id is provided", + "gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided", undefined, ], ])("%s", async (_, userId) => { @@ -423,11 +511,11 @@ describe("TokenService", () => { test.each([ [ - "should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", + "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", userIdFromAccessToken, ], [ - "should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided", + "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided", undefined, ], ])("%s", async (_, userId) => { @@ -455,11 +543,80 @@ describe("TokenService", () => { // Assert expect(result).toEqual(accessTokenJwt); }); + + it("logs the error and logs the user out when the access token key cannot be retrieved from secure storage if the access token is encrypted", async () => { + // This tests the intermittent windows 10/11 scenario in which the access token key was stored successfully in secure storage and the + // access token was encrypted with it and stored on disk successfully. However, on retrieval the access token key isn't able to + // retrieved for whatever reason. + + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, encryptedAccessToken]); + + // No access token key set + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + + // Assert + expect(result).toBeNull(); + + // assert that we logged the error + expect(logService.error).toHaveBeenCalledWith( + "Access token key not found to decrypt encrypted access token. Logging user out.", + ); + + // assert that we logged the user out + expect(logoutCallback).toHaveBeenCalledWith( + "accessTokenUnableToBeDecrypted", + userIdFromAccessToken, + ); + }); + + it("logs the error and logs the user out when secure storage errors on trying to get an access token key", async () => { + // This tests the linux scenario where users might not have secure storage support configured. + + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, encryptedAccessToken]); + + // Mock linux secure storage error + const secureStorageError = "Secure storage error"; + secureStorageService.get.mockRejectedValue(new Error(secureStorageError)); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + + // Assert + expect(result).toBeNull(); + + // assert that we logged the error + expect(logService.error).toHaveBeenCalledWith( + "Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.", + new Error(secureStorageError), + ); + + // assert that we logged the user out + expect(logoutCallback).toHaveBeenCalledWith( + "accessTokenUnableToBeDecrypted", + userIdFromAccessToken, + ); + }); }); }); describe("clearAccessToken", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error when no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = tokenService.clearAccessToken(); @@ -475,11 +632,11 @@ describe("TokenService", () => { test.each([ [ - "should clear the access token from all storage locations for the provided user id", + "clears the access token from all storage locations when a user id is provided", userIdFromAccessToken, ], [ - "should clear the access token from all storage locations for the global active user", + "clears the access token from all storage locations when there is a global active user", undefined, ], ])("%s", async (_, userId) => { @@ -519,7 +676,7 @@ describe("TokenService", () => { }); describe("decodeAccessToken", () => { - it("should throw an error if no access token provided or retrieved from state", async () => { + it("throws an error when no access token is provided or retrievable from state", async () => { // Access tokenService.getAccessToken = jest.fn().mockResolvedValue(null); @@ -530,7 +687,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Access token not found."); }); - it("should decode the access token", async () => { + it("decodes the access token when a valid one is stored", async () => { // Arrange tokenService.getAccessToken = jest.fn().mockResolvedValue(accessTokenJwt); @@ -544,7 +701,7 @@ describe("TokenService", () => { describe("Data methods", () => { describe("getTokenExpirationDate", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -555,7 +712,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should return null if the decoded access token is null", async () => { + it("returns null when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -566,7 +723,7 @@ describe("TokenService", () => { expect(result).toBeNull(); }); - it("should return null if the decoded access token does not have an expiration date", async () => { + it("returns null when the decoded access token does not have an expiration date", async () => { // Arrange const accessTokenDecodedWithoutExp = { ...accessTokenDecoded }; delete accessTokenDecodedWithoutExp.exp; @@ -581,7 +738,7 @@ describe("TokenService", () => { expect(result).toBeNull(); }); - it("should return null if the decoded access token has an non numeric expiration date", async () => { + it("returns null when the decoded access token has a non numeric expiration date", async () => { // Arrange const accessTokenDecodedWithNonNumericExp = { ...accessTokenDecoded, exp: "non-numeric" }; tokenService.decodeAccessToken = jest @@ -595,7 +752,7 @@ describe("TokenService", () => { expect(result).toBeNull(); }); - it("should return the expiration date of the access token", async () => { + it("returns the expiration date of the access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -608,7 +765,7 @@ describe("TokenService", () => { }); describe("tokenSecondsRemaining", () => { - it("should return 0 if the tokenExpirationDate is null", async () => { + it("returns 0 when the tokenExpirationDate is null", async () => { // Arrange tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null); @@ -619,7 +776,7 @@ describe("TokenService", () => { expect(result).toEqual(0); }); - it("should return the number of seconds remaining until the token expires", async () => { + it("returns the number of seconds remaining until the token expires", async () => { // Arrange // Lock the time to ensure a consistent test environment // otherwise we have flaky issues with set system time date and the Date.now() call. @@ -644,7 +801,7 @@ describe("TokenService", () => { jest.useRealTimers(); }); - it("should return the number of seconds remaining until the token expires, considering an offset", async () => { + it("returns the number of seconds remaining until the token expires when given an offset", async () => { // Arrange // Lock the time to ensure a consistent test environment // otherwise we have flaky issues with set system time date and the Date.now() call. @@ -672,7 +829,7 @@ describe("TokenService", () => { }); describe("tokenNeedsRefresh", () => { - it("should return true if token is within the default refresh threshold (5 min)", async () => { + it("returns true when the token is within the default refresh threshold (5 min)", async () => { // Arrange const tokenSecondsRemaining = 60; tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); @@ -684,7 +841,7 @@ describe("TokenService", () => { expect(result).toEqual(true); }); - it("should return false if token is outside the default refresh threshold (5 min)", async () => { + it("returns false when the token is outside the default refresh threshold (5 min)", async () => { // Arrange const tokenSecondsRemaining = 600; tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); @@ -696,7 +853,7 @@ describe("TokenService", () => { expect(result).toEqual(false); }); - it("should return true if token is within the specified refresh threshold", async () => { + it("returns true when the token is within the specified refresh threshold", async () => { // Arrange const tokenSecondsRemaining = 60; tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); @@ -708,7 +865,7 @@ describe("TokenService", () => { expect(result).toEqual(true); }); - it("should return false if token is outside the specified refresh threshold", async () => { + it("returns false when the token is outside the specified refresh threshold", async () => { // Arrange const tokenSecondsRemaining = 600; tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); @@ -722,7 +879,7 @@ describe("TokenService", () => { }); describe("getUserId", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -733,7 +890,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should throw an error if the decoded access token is null", async () => { + it("throws an error when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -744,7 +901,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No user id found"); }); - it("should throw an error if the decoded access token has a non-string user id", async () => { + it("throws an error when the decoded access token has a non-string user id", async () => { // Arrange const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 }; tokenService.decodeAccessToken = jest @@ -758,7 +915,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No user id found"); }); - it("should return the user id from the decoded access token", async () => { + it("returns the user id from the decoded access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -771,7 +928,7 @@ describe("TokenService", () => { }); describe("getUserIdFromAccessToken", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -782,7 +939,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should throw an error if the decoded access token is null", async () => { + it("throws an error when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -793,7 +950,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No user id found"); }); - it("should throw an error if the decoded access token has a non-string user id", async () => { + it("throws an error when the decoded access token has a non-string user id", async () => { // Arrange const accessTokenDecodedWithNonStringSub = { ...accessTokenDecoded, sub: 123 }; tokenService.decodeAccessToken = jest @@ -807,7 +964,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No user id found"); }); - it("should return the user id from the decoded access token", async () => { + it("returns the user id from the decoded access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -820,7 +977,7 @@ describe("TokenService", () => { }); describe("getEmail", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -831,7 +988,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should throw an error if the decoded access token is null", async () => { + it("throws an error when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -842,7 +999,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No email found"); }); - it("should throw an error if the decoded access token has a non-string email", async () => { + it("throws an error when the decoded access token has a non-string email", async () => { // Arrange const accessTokenDecodedWithNonStringEmail = { ...accessTokenDecoded, email: 123 }; tokenService.decodeAccessToken = jest @@ -856,7 +1013,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No email found"); }); - it("should return the email from the decoded access token", async () => { + it("returns the email from the decoded access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -869,7 +1026,7 @@ describe("TokenService", () => { }); describe("getEmailVerified", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -880,7 +1037,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should throw an error if the decoded access token is null", async () => { + it("throws an error when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -891,7 +1048,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No email verification found"); }); - it("should throw an error if the decoded access token has a non-boolean email_verified", async () => { + it("throws an error when the decoded access token has a non-boolean email_verified", async () => { // Arrange const accessTokenDecodedWithNonBooleanEmailVerified = { ...accessTokenDecoded, @@ -908,7 +1065,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No email verification found"); }); - it("should return the email_verified from the decoded access token", async () => { + it("returns the email_verified from the decoded access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -921,7 +1078,7 @@ describe("TokenService", () => { }); describe("getName", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -932,7 +1089,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should return null if the decoded access token is null", async () => { + it("returns null when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -943,7 +1100,7 @@ describe("TokenService", () => { expect(result).toBeNull(); }); - it("should return null if the decoded access token has a non-string name", async () => { + it("returns null when the decoded access token has a non-string name", async () => { // Arrange const accessTokenDecodedWithNonStringName = { ...accessTokenDecoded, name: 123 }; tokenService.decodeAccessToken = jest @@ -957,7 +1114,7 @@ describe("TokenService", () => { expect(result).toBeNull(); }); - it("should return the name from the decoded access token", async () => { + it("returns the name from the decoded access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -970,7 +1127,7 @@ describe("TokenService", () => { }); describe("getIssuer", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -981,7 +1138,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should throw an error if the decoded access token is null", async () => { + it("throws an error when the decoded access token is null", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); @@ -992,7 +1149,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No issuer found"); }); - it("should throw an error if the decoded access token has a non-string iss", async () => { + it("throws an error when the decoded access token has a non-string iss", async () => { // Arrange const accessTokenDecodedWithNonStringIss = { ...accessTokenDecoded, iss: 123 }; tokenService.decodeAccessToken = jest @@ -1006,7 +1163,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("No issuer found"); }); - it("should return the issuer from the decoded access token", async () => { + it("returns the issuer from the decoded access token when a valid access token is stored", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); @@ -1019,7 +1176,7 @@ describe("TokenService", () => { }); describe("getIsExternal", () => { - it("should throw an error if the access token cannot be decoded", async () => { + it("throws an error when the access token cannot be decoded", async () => { // Arrange tokenService.decodeAccessToken = jest.fn().mockRejectedValue(new Error("Mock error")); @@ -1030,7 +1187,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); - it("should return false if the amr (Authentication Method Reference) claim does not contain 'external'", async () => { + it("returns false when the amr (Authentication Method Reference) claim does not contain 'external'", async () => { // Arrange const accessTokenDecodedWithoutExternalAmr = { ...accessTokenDecoded, @@ -1047,7 +1204,7 @@ describe("TokenService", () => { expect(result).toEqual(false); }); - it("should return true if the amr (Authentication Method Reference) claim contains 'external'", async () => { + it("returns true when the amr (Authentication Method Reference) claim contains 'external'", async () => { // Arrange const accessTokenDecodedWithExternalAmr = { ...accessTokenDecoded, @@ -1073,7 +1230,7 @@ describe("TokenService", () => { const refreshTokenSecureStorageKey = `${userIdFromAccessToken}${refreshTokenPartialSecureStorageKey}`; describe("setRefreshToken", () => { - it("should throw an error if no user id is provided", async () => { + it("throws an error when no user id is provided", async () => { // Act // note: don't await here because we want to test the error const result = (tokenService as any).setRefreshToken( @@ -1113,7 +1270,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should set the refresh token in memory for the specified user id", async () => { + it("sets the refresh token in memory when given a user id", async () => { // Act await (tokenService as any).setRefreshToken( refreshToken, @@ -1130,7 +1287,7 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - it("should set the refresh token in disk for the specified user id", async () => { + it("sets the refresh token in disk when given a user id", async () => { // Act await (tokenService as any).setRefreshToken( refreshToken, @@ -1152,7 +1309,7 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("should set the refresh token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated for the specified user id", async () => { + it("sets the refresh token in secure storage, removes data on disk or in memory, and sets a flag to indicate the token has been migrated when given a user id", async () => { // Arrange: // For testing purposes, let's assume that the token is already in disk and memory singleUserStateProvider @@ -1163,6 +1320,9 @@ describe("TokenService", () => { .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) .stateSubject.next([userIdFromAccessToken, refreshToken]); + // We immediately call to get the refresh token from secure storage after setting it to ensure it was set. + secureStorageService.get.mockResolvedValue(refreshToken); + // Act await (tokenService as any).setRefreshToken( refreshToken, @@ -1187,18 +1347,166 @@ describe("TokenService", () => { singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, ).toHaveBeenCalledWith(null); }); + + it("tries to set the refresh token in secure storage then falls back to disk storage when the refresh token cannot be read back out of secure storage", async () => { + // Arrange: + // We immediately call to get the refresh token from secure storage after setting it to ensure it was set. + // So, set it to return null to mock a failure to set the refresh token in secure storage. + // This mocks the windows 10/11 intermittent issue where the token is not set in secure storage successfully. + secureStorageService.get.mockResolvedValue(null); + + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + // Assert + + // assert that the refresh token was set in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + refreshToken, + secureStorageOptions, + ); + + // assert that we tried to set the refresh token in secure storage, but it failed, so we reverted to disk storage + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(refreshToken); + }); + + it("tries to set the refresh token in secure storage, throws an error, then falls back to disk storage when secure storage isn't supported", async () => { + // Arrange: + // Mock the secure storage service to throw an error when trying to save the refresh token + // to simulate linux scenarios where a secure storage provider isn't configured. + secureStorageService.save.mockRejectedValue(new Error("Secure storage not supported")); + + // Act + await (tokenService as any).setRefreshToken( + refreshToken, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + // Assert + + // assert that the refresh token was set in secure storage + expect(secureStorageService.save).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + refreshToken, + secureStorageOptions, + ); + + // assert that we tried to set the refresh token in secure storage, but it failed, so we reverted to disk storage + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(refreshToken); + }); + + it("returns the unencrypted access token when secure storage retrieval fails but the access token is still pre-migration", async () => { + // This tests the linux scenario where users might not have secure storage support configured. + + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Mock linux secure storage error + const secureStorageError = "Secure storage error"; + secureStorageService.get.mockRejectedValue(new Error(secureStorageError)); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + + // Assert + // assert that we returned the unencrypted, pre-migration access token + expect(result).toBe(accessTokenJwt); + + // assert that we did not log an error or log the user out + expect(logService.error).not.toHaveBeenCalled(); + + expect(logoutCallback).not.toHaveBeenCalled(); + }); + + it("does not error and fallback to disk storage when passed a null value for the refresh token", async () => { + // Arrange + secureStorageService.get.mockResolvedValue(null); + + // Act + await (tokenService as any).setRefreshToken( + null, + diskVaultTimeoutAction, + diskVaultTimeout, + userIdFromAccessToken, + ); + + // Assert + expect(secureStorageService.save).toHaveBeenCalledWith( + refreshTokenSecureStorageKey, + null, + secureStorageOptions, + ); + + expect(logService.error).not.toHaveBeenCalled(); + + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK).nextMock, + ).toHaveBeenCalledWith(null); + + expect( + singleUserStateProvider.getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY).nextMock, + ).toHaveBeenCalledWith(null); + }); + + it("logs the error and logs the user out when the access token cannot be decrypted", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, encryptedAccessToken]); + + secureStorageService.get.mockResolvedValue(accessTokenKeyB64); + encryptService.decryptToUtf8.mockRejectedValue(new Error("Decryption error")); + + // Act + const result = await tokenService.getAccessToken(userIdFromAccessToken); + + // Assert + expect(result).toBeNull(); + + // assert that we logged the error + expect(logService.error).toHaveBeenCalledWith( + "Failed to decrypt access token", + new Error("Decryption error"), + ); + + // assert that we logged the user out + expect(logoutCallback).toHaveBeenCalledWith( + "accessTokenUnableToBeDecrypted", + userIdFromAccessToken, + ); + }); }); }); describe("getRefreshToken", () => { - it("should return undefined if no user id is provided and there is no active user in global state", async () => { + it("returns null when no user id is provided and there is no active user in global state", async () => { // Act const result = await (tokenService as any).getRefreshToken(); // Assert - expect(result).toBeUndefined(); + expect(result).toBeNull(); }); - it("should return null if no refresh token is found in memory, disk, or secure storage", async () => { + it("returns null when no refresh token is found in memory, disk, or secure storage", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1211,7 +1519,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should get the refresh token from memory with no user id specified (uses global active user)", async () => { + it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1233,7 +1541,7 @@ describe("TokenService", () => { expect(result).toEqual(refreshToken); }); - it("should get the refresh token from memory for the specified user id", async () => { + it("gets the refresh token from memory when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1251,7 +1559,7 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - it("should get the refresh token from disk with no user id specified", async () => { + it("gets the refresh token from disk when no user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1272,7 +1580,7 @@ describe("TokenService", () => { expect(result).toEqual(refreshToken); }); - it("should get the refresh token from disk for the specified user id", async () => { + it("gets the refresh token from disk when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1295,7 +1603,7 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("should get the refresh token from secure storage when no user id is specified and the migration flag is set to true", async () => { + it("gets the refresh token from secure storage when no user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1318,7 +1626,7 @@ describe("TokenService", () => { expect(result).toEqual(refreshToken); }); - it("should get the refresh token from secure storage when user id is specified and the migration flag set to true", async () => { + it("gets the refresh token from secure storage when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1337,7 +1645,7 @@ describe("TokenService", () => { expect(result).toEqual(refreshToken); }); - it("should fallback and get the refresh token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + it("falls back and gets the refresh token from disk when a user id is specified even if the platform supports secure storage", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1357,7 +1665,7 @@ describe("TokenService", () => { expect(secureStorageService.get).not.toHaveBeenCalled(); }); - it("should fallback and get the refresh token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => { + it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1381,11 +1689,80 @@ describe("TokenService", () => { // assert that secure storage was not called expect(secureStorageService.get).not.toHaveBeenCalled(); }); + + it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => { + // Arrange + secureStorageService.get.mockResolvedValue(null); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + + // Assert + expect(result).toBeNull(); + }); + + it("returns null and logs when the refresh token is not found in secure storage when it should be", async () => { + // This scenario mocks the case where we have intermittent windows 10/11 issues w/ secure storage not + // returning the refresh token when it should be there. + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + secureStorageService.get.mockResolvedValue(null); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + + // Assert + expect(result).toBeNull(); + + expect(logService.error).toHaveBeenCalledWith( + "Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.", + ); + }); + + it("logs out when retrieving the refresh token out of secure storage errors", async () => { + // This scenario mocks the case where linux users don't have secure storage configured. + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, undefined]); + + const secureStorageSvcMockErrorMsg = "Secure storage retrieval error"; + + secureStorageService.get.mockRejectedValue(new Error(secureStorageSvcMockErrorMsg)); + + // Act + const result = await tokenService.getRefreshToken(userIdFromAccessToken); + + // Assert + expect(result).toBeNull(); + + // expect that we logged an error and logged the user out + expect(logService.error).toHaveBeenCalledWith( + `Failed to retrieve refresh token from secure storage`, + new Error(secureStorageSvcMockErrorMsg), + ); + + expect(logoutCallback).toHaveBeenCalledWith( + "refreshTokenSecureStorageRetrievalFailure", + userIdFromAccessToken, + ); + }); }); }); describe("clearRefreshToken", () => { - it("should throw an error if no user id is provided", async () => { + it("throws an error when no user id is provided", async () => { // Act // note: don't await here because we want to test the error const result = (tokenService as any).clearRefreshToken(); @@ -1399,7 +1776,7 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("should clear the refresh token from all storage locations for the specified user id", async () => { + it("clears the refresh token from all storage locations when given a user id", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) @@ -1433,7 +1810,7 @@ describe("TokenService", () => { const clientId = "clientId"; describe("setClientId", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error when no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = tokenService.setClientId(clientId, VaultTimeoutAction.Lock, null); @@ -1470,7 +1847,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should set the client id in memory when there is an active user in global state", async () => { + it("sets the client id in memory when there is an active user in global state", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1486,7 +1863,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(clientId); }); - it("should set the client id in memory for the specified user id", async () => { + it("sets the client id in memory when given a user id", async () => { // Act await tokenService.setClientId( clientId, @@ -1504,7 +1881,7 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("should set the client id in disk when there is an active user in global state", async () => { + it("sets the client id in disk when there is an active user in global state", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1519,7 +1896,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(clientId); }); - it("should set the client id in disk for the specified user id", async () => { + it("sets the client id on disk when given a user id", async () => { // Act await tokenService.setClientId( clientId, @@ -1537,14 +1914,14 @@ describe("TokenService", () => { }); describe("getClientId", () => { - it("should return undefined if no user id is provided and there is no active user in global state", async () => { + it("returns undefined when no user id is provided and there is no active user in global state", async () => { // Act const result = await tokenService.getClientId(); // Assert expect(result).toBeUndefined(); }); - it("should return null if no client id is found in memory or disk", async () => { + it("returns null when no client id is found in memory or disk", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1557,7 +1934,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should get the client id from memory with no user id specified (uses global active user)", async () => { + it("gets the client id from memory when no user id is specified (uses global active user)", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) @@ -1580,7 +1957,7 @@ describe("TokenService", () => { expect(result).toEqual(clientId); }); - it("should get the client id from memory for the specified user id", async () => { + it("gets the client id from memory when given a user id", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) @@ -1599,7 +1976,7 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("should get the client id from disk with no user id specified", async () => { + it("gets the client id from disk when no user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) @@ -1620,7 +1997,7 @@ describe("TokenService", () => { expect(result).toEqual(clientId); }); - it("should get the client id from disk for the specified user id", async () => { + it("gets the client id from disk when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) @@ -1639,7 +2016,7 @@ describe("TokenService", () => { }); describe("clearClientId", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error when no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = (tokenService as any).clearClientId(); @@ -1647,7 +2024,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("User id not found. Cannot clear client id."); }); - it("should clear the client id from memory and disk for the specified user id", async () => { + it("clears the client id from memory and disk when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) @@ -1669,7 +2046,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(null); }); - it("should clear the client id from memory and disk for the global active user", async () => { + it("clears the client id from memory and disk when there is a global active user", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) @@ -1702,7 +2079,7 @@ describe("TokenService", () => { const clientSecret = "clientSecret"; describe("setClientSecret", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error when no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = tokenService.setClientSecret( @@ -1747,7 +2124,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should set the client secret in memory when there is an active user in global state", async () => { + it("sets the client secret in memory when there is an active user in global state", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1767,7 +2144,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(clientSecret); }); - it("should set the client secret in memory for the specified user id", async () => { + it("sets the client secret in memory when a user id is specified", async () => { // Act await tokenService.setClientSecret( clientSecret, @@ -1785,7 +2162,7 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("should set the client secret in disk when there is an active user in global state", async () => { + it("sets the client secret on disk when there is an active user in global state", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1805,7 +2182,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(clientSecret); }); - it("should set the client secret in disk for the specified user id", async () => { + it("sets the client secret on disk when a user id is specified", async () => { // Act await tokenService.setClientSecret( clientSecret, @@ -1824,14 +2201,14 @@ describe("TokenService", () => { }); describe("getClientSecret", () => { - it("should return undefined if no user id is provided and there is no active user in global state", async () => { + it("returns undefined when no user id is provided and there is no active user in global state", async () => { // Act const result = await tokenService.getClientSecret(); // Assert expect(result).toBeUndefined(); }); - it("should return null if no client secret is found in memory or disk", async () => { + it("returns null when no client secret is found in memory or disk", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -1844,7 +2221,7 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("should get the client secret from memory with no user id specified (uses global active user)", async () => { + it("gets the client secret from memory when no user id is specified (uses global active user)", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) @@ -1867,7 +2244,7 @@ describe("TokenService", () => { expect(result).toEqual(clientSecret); }); - it("should get the client secret from memory for the specified user id", async () => { + it("gets the client secret from memory when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) @@ -1886,7 +2263,7 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("should get the client secret from disk with no user id specified", async () => { + it("gets the client secret from disk when no user id specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) @@ -1907,7 +2284,7 @@ describe("TokenService", () => { expect(result).toEqual(clientSecret); }); - it("should get the client secret from disk for the specified user id", async () => { + it("gets the client secret from disk when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) @@ -1926,7 +2303,7 @@ describe("TokenService", () => { }); describe("clearClientSecret", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error when no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = (tokenService as any).clearClientSecret(); @@ -1934,7 +2311,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("User id not found. Cannot clear client secret."); }); - it("should clear the client secret from memory and disk for the specified user id", async () => { + it("clears the client secret from memory and disk when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) @@ -1958,7 +2335,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(null); }); - it("should clear the client secret from memory and disk for the global active user", async () => { + it("clears the client secret from memory and disk when there is a global active user", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) @@ -1990,7 +2367,7 @@ describe("TokenService", () => { }); describe("setTokens", () => { - it("should call to set all passed in tokens after deriving user id from the access token", async () => { + it("calls to set all tokens after deriving user id from the access token when called with valid params", async () => { // Arrange const refreshToken = "refreshToken"; // specific vault timeout actions and vault timeouts don't change this test so values don't matter. @@ -2042,7 +2419,7 @@ describe("TokenService", () => { ); }); - it("should not try to set client id and client secret if they are not passed in", async () => { + it("does not try to set client id and client secret when they are not passed in", async () => { // Arrange const refreshToken = "refreshToken"; const vaultTimeoutAction = VaultTimeoutAction.Lock; @@ -2076,7 +2453,7 @@ describe("TokenService", () => { expect(tokenService.setClientSecret).not.toHaveBeenCalled(); }); - it("should throw an error if the access token is invalid", async () => { + it("throws an error when the access token is invalid", async () => { // Arrange const accessToken = "invalidToken"; const refreshToken = "refreshToken"; @@ -2095,7 +2472,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("JWT must have 3 parts"); }); - it("should throw an error if the access token is missing", async () => { + it("throws an error when the access token is missing", async () => { // Arrange const accessToken: string = null; const refreshToken = "refreshToken"; @@ -2150,7 +2527,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("Vault Timeout Action is required."); }); - it("should not throw an error if the refresh token is missing and it should just not set it", async () => { + it("does not throw an error or set the refresh token when the refresh token is missing", async () => { // Arrange const refreshToken: string = null; const vaultTimeoutAction = VaultTimeoutAction.Lock; @@ -2166,7 +2543,7 @@ describe("TokenService", () => { }); describe("clearTokens", () => { - it("should call to clear all tokens for the specified user id", async () => { + it("calls to clear all tokens when given a specified user id", async () => { // Arrange const userId = "userId" as UserId; @@ -2187,7 +2564,7 @@ describe("TokenService", () => { expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId); }); - it("should call to clear all tokens for the active user id", async () => { + it("calls to clear all tokens when there is an active user", async () => { // Arrange const userId = "userId" as UserId; @@ -2210,7 +2587,7 @@ describe("TokenService", () => { expect((tokenService as any).clearClientSecret).toHaveBeenCalledWith(userId); }); - it("should not call to clear all tokens if no user id is provided and there is no active user in global state", async () => { + it("does not call to clear all tokens when no user id is provided and there is no active user in global state", async () => { // Arrange tokenService.clearAccessToken = jest.fn(); (tokenService as any).clearRefreshToken = jest.fn(); @@ -2228,7 +2605,7 @@ describe("TokenService", () => { describe("Two Factor Token methods", () => { describe("setTwoFactorToken", () => { - it("should set the email and two factor token when there hasn't been a previous record (initializing the record)", async () => { + it("sets the email and two factor token when there hasn't been a previous record (initializing the record)", async () => { // Arrange const email = "testUser@email.com"; const twoFactorToken = "twoFactorTokenForTestUser"; @@ -2240,7 +2617,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith({ [email]: twoFactorToken }); }); - it("should set the email and two factor token when there is an initialized value already (updating the existing record)", async () => { + it("sets the email and two factor token when there is an initialized value already (updating the existing record)", async () => { // Arrange const email = "testUser@email.com"; const twoFactorToken = "twoFactorTokenForTestUser"; @@ -2263,7 +2640,7 @@ describe("TokenService", () => { }); describe("getTwoFactorToken", () => { - it("should return the two factor token for the given email", async () => { + it("returns the two factor token when given an email", async () => { // Arrange const email = "testUser"; const twoFactorToken = "twoFactorTokenForTestUser"; @@ -2282,7 +2659,7 @@ describe("TokenService", () => { expect(result).toEqual(twoFactorToken); }); - it("should not return the two factor token for an email that doesn't exist", async () => { + it("does not return the two factor token when given an email that doesn't exist", async () => { // Arrange const email = "testUser"; const initialTwoFactorTokenRecord: Record = { @@ -2300,7 +2677,7 @@ describe("TokenService", () => { expect(result).toEqual(undefined); }); - it("should return null if there is no two factor token record", async () => { + it("returns null when there is no two factor token record", async () => { // Arrange globalStateProvider .getFake(EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL) @@ -2315,7 +2692,7 @@ describe("TokenService", () => { }); describe("clearTwoFactorToken", () => { - it("should clear the two factor token for the given email when a record exists", async () => { + it("clears the two factor token for the given email when a record exists", async () => { // Arrange const email = "testUser"; const twoFactorToken = "twoFactorTokenForTestUser"; @@ -2336,7 +2713,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith({}); }); - it("should initialize the record if it doesn't exist and delete the value", async () => { + it("initializes the record and deletes the value when the record doesn't exist", async () => { // Arrange const email = "testUser"; @@ -2355,7 +2732,7 @@ describe("TokenService", () => { const mockSecurityStamp = "securityStamp"; describe("setSecurityStamp", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error deletes the value no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = tokenService.setSecurityStamp(mockSecurityStamp); @@ -2363,7 +2740,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("User id not found. Cannot set security stamp."); }); - it("should set the security stamp in memory when there is an active user in global state", async () => { + it("sets the security stamp in memory when there is an active user in global state", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -2378,7 +2755,7 @@ describe("TokenService", () => { ).toHaveBeenCalledWith(mockSecurityStamp); }); - it("should set the security stamp in memory for the specified user id", async () => { + it("sets the security stamp in memory when a user id is specified", async () => { // Act await tokenService.setSecurityStamp(mockSecurityStamp, userIdFromAccessToken); @@ -2390,7 +2767,7 @@ describe("TokenService", () => { }); describe("getSecurityStamp", () => { - it("should throw an error if no user id is provided and there is no active user in global state", async () => { + it("throws an error when no user id is provided and there is no active user in global state", async () => { // Act // note: don't await here because we want to test the error const result = tokenService.getSecurityStamp(); @@ -2398,7 +2775,7 @@ describe("TokenService", () => { await expect(result).rejects.toThrow("User id not found. Cannot get security stamp."); }); - it("should return the security stamp from memory with no user id specified (uses global active user)", async () => { + it("returns the security stamp from memory when no user id is specified (uses global active user)", async () => { // Arrange globalStateProvider .getFake(ACCOUNT_ACTIVE_ACCOUNT_ID) @@ -2415,7 +2792,7 @@ describe("TokenService", () => { expect(result).toEqual(mockSecurityStamp); }); - it("should return the security stamp from memory for the specified user id", async () => { + it("returns the security stamp from memory when a user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, SECURITY_STAMP_MEMORY) @@ -2601,6 +2978,7 @@ describe("TokenService", () => { keyGenerationService, encryptService, logService, + logoutCallback, ); } }); diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 203d95429e..38d0a77b52 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -1,7 +1,7 @@ import { Observable, combineLatest, firstValueFrom, map } from "rxjs"; import { Opaque } from "type-fest"; -import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; +import { LogoutReason, decodeJwtTokenToJson } from "@bitwarden/auth/common"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; @@ -111,7 +111,7 @@ export type DecodedAccessToken = { * A symmetric key for encrypting the access token before the token is stored on disk. * This key should be stored in secure storage. * */ -type AccessTokenKey = Opaque; +export type AccessTokenKey = Opaque; export class TokenService implements TokenServiceAbstraction { private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey"; @@ -132,6 +132,7 @@ export class TokenService implements TokenServiceAbstraction { private keyGenerationService: KeyGenerationService, private encryptService: EncryptService, private logService: LogService, + private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise, ) { this.initializeState(); } @@ -145,10 +146,6 @@ export class TokenService implements TokenServiceAbstraction { ]).pipe(map(([disk, memory]) => Boolean(disk || memory))); } - // pivoting to an approach where we create a symmetric key we store in secure storage - // which is used to protect the data before persisting to disk. - // We will also use the same symmetric key to decrypt the data when reading from disk. - private initializeState(): void { this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get( EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL, @@ -218,6 +215,14 @@ export class TokenService implements TokenServiceAbstraction { this.getSecureStorageOptions(userId), ); + // We are having intermittent issues with access token keys not saving into secure storage on windows 10/11. + // So, let's add a check to ensure we can read the value after writing it. + const accessTokenKey = await this.getAccessTokenKey(userId); + + if (!accessTokenKey) { + throw new Error("New Access token key unable to be retrieved from secure storage."); + } + return newAccessTokenKey; } @@ -238,6 +243,8 @@ export class TokenService implements TokenServiceAbstraction { } // First see if we have an accessTokenKey in secure storage and return it if we do + // Note: retrieving/saving data from/to secure storage on linux will throw if the + // distro doesn't have a secure storage provider let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId); if (!accessTokenKey) { @@ -255,15 +262,13 @@ export class TokenService implements TokenServiceAbstraction { } private async decryptAccessToken( + accessTokenKey: AccessTokenKey, encryptedAccessToken: EncString, - userId: UserId, ): Promise { - const accessTokenKey = await this.getAccessTokenKey(userId); - if (!accessTokenKey) { - // If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet - // and we have to return null here to properly indicate the user isn't logged in. - return null; + throw new Error( + "decryptAccessToken: Access token key required. Cannot decrypt access token.", + ); } const decryptedAccessToken = await this.encryptService.decryptToUtf8( @@ -297,17 +302,32 @@ export class TokenService implements TokenServiceAbstraction { // store the access token directly. Instead, we encrypt with accessTokenKey and store that // in secure storage. - const encryptedAccessToken: EncString = await this.encryptAccessToken(accessToken, userId); + try { + const encryptedAccessToken: EncString = await this.encryptAccessToken( + accessToken, + userId, + ); - // Save the encrypted access token to disk - await this.singleUserStateProvider - .get(userId, ACCESS_TOKEN_DISK) - .update((_) => encryptedAccessToken.encryptedString); + // Save the encrypted access token to disk + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_DISK) + .update((_) => encryptedAccessToken.encryptedString); - // TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408 - // 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time. - // Remove this call to remove the access token from memory after 3 releases. - await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); + // TODO: PM-6408 + // 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time. + // Remove this call to remove the access token from memory after 3 months. + await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); + } catch (error) { + this.logService.error( + `SetAccessToken: storing encrypted access token in secure storage failed. Falling back to disk storage.`, + error, + ); + + // Fall back to disk storage for unecrypted access token + await this.singleUserStateProvider + .get(userId, ACCESS_TOKEN_DISK) + .update((_) => accessToken); + } return; } @@ -376,11 +396,11 @@ export class TokenService implements TokenServiceAbstraction { await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } - async getAccessToken(userId?: UserId): Promise { + async getAccessToken(userId?: UserId): Promise { userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); if (!userId) { - return undefined; + return null; } // Try to get the access token from memory @@ -399,10 +419,41 @@ export class TokenService implements TokenServiceAbstraction { } if (this.platformSupportsSecureStorage) { - const accessTokenKey = await this.getAccessTokenKey(userId); + let accessTokenKey: AccessTokenKey; + try { + accessTokenKey = await this.getAccessTokenKey(userId); + } catch (error) { + if (EncString.isSerializedEncString(accessTokenDisk)) { + this.logService.error( + "Access token key retrieval failed. Unable to decrypt encrypted access token. Logging user out.", + error, + ); + await this.logoutCallback("accessTokenUnableToBeDecrypted", userId); + return null; + } + + // If the access token key is not found, but the access token is unencrypted then + // this indicates that this is the pre-migration state where the access token + // was stored unencrypted on disk. We can return the access token as is. + // Note: this is likely to only be hit for linux users who don't + // have a secure storage provider configured. + return accessTokenDisk; + } if (!accessTokenKey) { - // We know this is an unencrypted access token because we don't have an access token key + if (EncString.isSerializedEncString(accessTokenDisk)) { + // The access token is encrypted but we don't have the key to decrypt it for + // whatever reason so we have to log the user out. + this.logService.error( + "Access token key not found to decrypt encrypted access token. Logging user out.", + ); + + await this.logoutCallback("accessTokenUnableToBeDecrypted", userId); + + return null; + } + + // We know this is an unencrypted access token return accessTokenDisk; } @@ -410,17 +461,18 @@ export class TokenService implements TokenServiceAbstraction { const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString); const decryptedAccessToken = await this.decryptAccessToken( + accessTokenKey, encryptedAccessTokenEncString, - userId, ); return decryptedAccessToken; } catch (error) { - // If an error occurs during decryption, return null for logout. + // If an error occurs during decryption, logout and then return null. // We don't try to recover here since we'd like to know // if access token and key are getting out of sync. - this.logService.error( - `Failed to decrypt access token: ${error?.message ?? "Unknown error."}`, - ); + this.logService.error(`Failed to decrypt access token`, error); + + await this.logoutCallback("accessTokenUnableToBeDecrypted", userId); + return null; } } @@ -456,21 +508,49 @@ export class TokenService implements TokenServiceAbstraction { ); switch (storageLocation) { - case TokenStorageLocation.SecureStorage: - await this.saveStringToSecureStorage( - userId, - this.refreshTokenSecureStorageKey, - refreshToken, - ); + case TokenStorageLocation.SecureStorage: { + try { + await this.saveStringToSecureStorage( + userId, + this.refreshTokenSecureStorageKey, + refreshToken, + ); - // TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408 - // 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time. - // Remove these 2 calls to remove the refresh token from memory and disk after 3 releases. - await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); - await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); + // Check if the refresh token was able to be saved to secure storage by reading it + // immediately after setting it. This is needed due to intermittent silent failures on Windows 10/11. + const refreshTokenSecureStorage = await this.getStringFromSecureStorage( + userId, + this.refreshTokenSecureStorageKey, + ); + + // Only throw if the refresh token was not saved to secure storage + // If we only check for a nullish value out of secure storage without considering the input value, + // then we would end up falling back to disk storage if the input value was null. + if (refreshToken !== null && !refreshTokenSecureStorage) { + throw new Error("Refresh token failed to save to secure storage."); + } + + // TODO: PM-6408 + // 2024-02-20: Remove refresh token from memory and disk so that we migrate to secure storage over time. + // Remove these 2 calls to remove the refresh token from memory and disk after 3 months. + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_DISK).update((_) => null); + await this.singleUserStateProvider.get(userId, REFRESH_TOKEN_MEMORY).update((_) => null); + } catch (error) { + // This case could be hit for both Linux users who don't have secure storage configured + // or for Windows users who have intermittent issues with secure storage. + this.logService.error( + `SetRefreshToken: storing refresh token in secure storage failed. Falling back to disk storage.`, + error, + ); + + // Fall back to disk storage for refresh token + await this.singleUserStateProvider + .get(userId, REFRESH_TOKEN_DISK) + .update((_) => refreshToken); + } return; - + } case TokenStorageLocation.Disk: await this.singleUserStateProvider .get(userId, REFRESH_TOKEN_DISK) @@ -485,11 +565,11 @@ export class TokenService implements TokenServiceAbstraction { } } - async getRefreshToken(userId?: UserId): Promise { + async getRefreshToken(userId?: UserId): Promise { userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); if (!userId) { - return undefined; + return null; } // pre-secure storage migration: @@ -507,17 +587,30 @@ export class TokenService implements TokenServiceAbstraction { const refreshTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, REFRESH_TOKEN_DISK); if (refreshTokenDisk != null) { + // This handles the scenario pre-secure storage migration where the refresh token was stored on disk. return refreshTokenDisk; } if (this.platformSupportsSecureStorage) { - const refreshTokenSecureStorage = await this.getStringFromSecureStorage( - userId, - this.refreshTokenSecureStorageKey, - ); + try { + const refreshTokenSecureStorage = await this.getStringFromSecureStorage( + userId, + this.refreshTokenSecureStorageKey, + ); - if (refreshTokenSecureStorage != null) { - return refreshTokenSecureStorage; + if (refreshTokenSecureStorage != null) { + return refreshTokenSecureStorage; + } + + this.logService.error( + "Refresh token not found in secure storage. Access token will fail to refresh upon expiration or manual refresh.", + ); + } catch (error) { + // This case will be hit for Linux users who don't have secure storage configured. + + this.logService.error(`Failed to retrieve refresh token from secure storage`, error); + + await this.logoutCallback("refreshTokenSecureStorageRetrievalFailure", userId); } } diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 7561023a27..85640519ec 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -140,6 +140,10 @@ export class UserVerificationService implements UserVerificationServiceAbstracti * @param verification User-supplied verification data (OTP, MP, PIN, or biometrics) */ async verifyUser(verification: Verification): Promise { + if (verification == null) { + throw new Error("Verification is required."); + } + const [userId, email] = await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])), ); diff --git a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts index 063b3c370b..117b318768 100644 --- a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -1,3 +1,9 @@ +import { PaymentMethodType } from "@bitwarden/common/billing/enums"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; +import { TokenizedPaymentMethodRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-method.request"; +import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request"; +import { PaymentInformationResponse } from "@bitwarden/common/billing/models/response/payment-information.response"; + import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; @@ -13,23 +19,50 @@ export abstract class BillingApiServiceAbstraction { organizationId: string, request: SubscriptionCancellationRequest, ) => Promise; + cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise; + createClientOrganization: ( providerId: string, request: CreateClientOrganizationRequest, ) => Promise; + + createSetupIntent: (paymentMethodType: PaymentMethodType) => Promise; + getBillingStatus: (id: string) => Promise; + getOrganizationBillingMetadata: ( organizationId: string, ) => Promise; + getOrganizationSubscription: ( organizationId: string, ) => Promise; + getPlans: () => Promise>; + + getProviderPaymentInformation: (providerId: string) => Promise; + getProviderSubscription: (providerId: string) => Promise; + updateClientOrganization: ( providerId: string, organizationId: string, request: UpdateClientOrganizationRequest, ) => Promise; + + updateProviderPaymentMethod: ( + providerId: string, + request: TokenizedPaymentMethodRequest, + ) => Promise; + + updateProviderTaxInformation: ( + providerId: string, + request: ExpandedTaxInfoUpdateRequest, + ) => Promise; + + verifyProviderBankAccount: ( + providerId: string, + request: VerifyBankAccountRequest, + ) => Promise; } diff --git a/libs/common/src/billing/abstractions/index.ts b/libs/common/src/billing/abstractions/index.ts new file mode 100644 index 0000000000..08a7a28fd9 --- /dev/null +++ b/libs/common/src/billing/abstractions/index.ts @@ -0,0 +1,7 @@ +export * from "./account/billing-account-profile-state.service"; +export * from "./billilng-api.service.abstraction"; +export * from "./organization-billing.service"; +export * from "./payment-method-warnings-service.abstraction"; +export * from "./payment-processors/braintree.service.abstraction"; +export * from "./payment-processors/stripe.service.abstraction"; +export * from "./provider-billing.service.abstraction"; diff --git a/libs/common/src/billing/abstractions/payment-processors/braintree.service.abstraction.ts b/libs/common/src/billing/abstractions/payment-processors/braintree.service.abstraction.ts new file mode 100644 index 0000000000..9391ab25f5 --- /dev/null +++ b/libs/common/src/billing/abstractions/payment-processors/braintree.service.abstraction.ts @@ -0,0 +1,28 @@ +export abstract class BraintreeServiceAbstraction { + /** + * Utilizes the Braintree SDK to create a [Braintree drop-in]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html} instance attached to the container ID specified as part of the {@link loadBraintree} method. + */ + createDropin: () => void; + + /** + * Loads the Bitwarden dropin.js script in the element of the current page. + * This script attaches the Braintree SDK to the window. + * @param containerId - The ID of the HTML element where the Braintree drop-in will be loaded at. + * @param autoCreateDropin - Specifies whether the Braintree drop-in should be created when dropin.js loads. + */ + loadBraintree: (containerId: string, autoCreateDropin: boolean) => void; + + /** + * Invokes the Braintree [requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod} method + * in order to generate a payment method token using the active Braintree drop-in. + */ + requestPaymentMethod: () => Promise; + + /** + * Removes the following elements from the of the current page: + * - The Bitwarden dropin.js script + * - Any